added more tests and ui updates
All checks were successful
Build / build (push) Successful in 11m48s
All checks were successful
Build / build (push) Successful in 11m48s
This commit is contained in:
parent
4d6600b383
commit
6498ac56d9
|
@ -14,25 +14,6 @@ const App: FunctionComponent = () => {
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Container>
|
<Container>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* <div className="grid grid-cols-3 justify-between text-slate-500 font-medium">
|
|
||||||
<div className="flex-flex-row mr-auto text-lg">
|
|
||||||
<Button text="GoShort" link="/" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row mx-auto">
|
|
||||||
{isAuthenticated && (
|
|
||||||
<>
|
|
||||||
<Button text="Shorts" link="sht" />
|
|
||||||
<Button text="Tokens" link="tkn" />
|
|
||||||
<Button text="Sessions" link="ses" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row ml-auto">
|
|
||||||
{isAuthenticated || <Button text="Signup" link="sgn" />}
|
|
||||||
{isAuthenticated || <Button text="Login" link="lgn" />}
|
|
||||||
{isAuthenticated && <Button text="Logout" link="lgo" />}
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,7 +14,7 @@ type DefaultButtonProps = DetailedHTMLProps<
|
||||||
const Button: FunctionComponent<
|
const Button: FunctionComponent<
|
||||||
Pick<
|
Pick<
|
||||||
DefaultButtonProps,
|
DefaultButtonProps,
|
||||||
"className" | "onClick" | "type" | "disabled" | "children"
|
"className" | "onClick" | "type" | "disabled" | "children" | "id"
|
||||||
> & {
|
> & {
|
||||||
color?: "blue" | "red" | "green"
|
color?: "blue" | "red" | "green"
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ export default function Navbar() {
|
||||||
{ name: "Tokens", href: "tkn" },
|
{ name: "Tokens", href: "tkn" },
|
||||||
{ name: "Sessions", href: "ses" },
|
{ name: "Sessions", href: "ses" },
|
||||||
]
|
]
|
||||||
const unauthed = [{ name: "Signup", href: "sgn" }]
|
const unauthed = [{ name: "Register", href: "sgn" }]
|
||||||
return isAuthenticated ? authed : unauthed
|
return isAuthenticated ? authed : unauthed
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
@ -72,7 +72,9 @@ export default function Navbar() {
|
||||||
<Menu.Button className="relative py-2 justify-items-center flex rounded-lg focus:outline-none focus:ring-none">
|
<Menu.Button className="relative py-2 justify-items-center flex rounded-lg focus:outline-none focus:ring-none">
|
||||||
<span className="absolute -inset-1.5" />
|
<span className="absolute -inset-1.5" />
|
||||||
<span className="sr-only">Open user menu</span>
|
<span className="sr-only">Open user menu</span>
|
||||||
<span className="my-auto">{user?.username}</span>
|
<span className="my-auto">
|
||||||
|
{user?.username.split("@")[0]}
|
||||||
|
</span>
|
||||||
<span className="my-auto">
|
<span className="my-auto">
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className="h-4 w-4 ml-1"
|
className="h-4 w-4 ml-1"
|
||||||
|
@ -89,7 +91,24 @@ export default function Navbar() {
|
||||||
leave="transition ease-in duration-75"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95">
|
leaveTo="transform opacity-0 scale-95">
|
||||||
<Menu.Items className="absolute right-0 z-10 mt-2 w-32 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
<Menu.Items className="absolute right-0 z-10 mt-2 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none divide-solid divide-y w-min">
|
||||||
|
<Menu.Item>
|
||||||
|
<div className="block px-4 py-2 text-xs font-semibold text-gray-700">
|
||||||
|
{user?.username}
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<Link
|
||||||
|
to="/acc"
|
||||||
|
className={classNames(
|
||||||
|
active ? "bg-gray-100" : "",
|
||||||
|
"block px-4 py-2 text-sm text-gray-700"
|
||||||
|
)}>
|
||||||
|
Account
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { ChangeEventHandler, FunctionComponent, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Link,
|
||||||
|
useActionData,
|
||||||
|
useNavigation,
|
||||||
|
useSearchParams,
|
||||||
|
} from "react-router-dom"
|
||||||
|
|
||||||
|
import Button from "./Button"
|
||||||
|
|
||||||
|
const actionOptions = {
|
||||||
|
login: {
|
||||||
|
title: "Log in",
|
||||||
|
buttonText: "Log in",
|
||||||
|
buttonLoadingText: "Logging in...",
|
||||||
|
buttonID: "login",
|
||||||
|
passAutoComplete: "current-password",
|
||||||
|
altLabel: "Don't have an account?",
|
||||||
|
altLinkText: "Register",
|
||||||
|
altLink: "/sgn",
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
title: "Register",
|
||||||
|
buttonText: "Register",
|
||||||
|
buttonLoadingText: "Registering...",
|
||||||
|
buttonID: "register",
|
||||||
|
passAutoComplete: "new-password",
|
||||||
|
altLabel: "Have an account?",
|
||||||
|
altLinkText: "Log in",
|
||||||
|
altLink: "/lgn",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param password password to score
|
||||||
|
* @returns password score, unbound
|
||||||
|
*/
|
||||||
|
const passwordOMeter = (password: string): number => {
|
||||||
|
let score = 0
|
||||||
|
const length = password.length
|
||||||
|
if (length <= 8) return score
|
||||||
|
|
||||||
|
const lowercases = password.match(/[a-z]/)?.length || 0
|
||||||
|
const uppercases = password.match(/[A-Z]/)?.length || 0
|
||||||
|
const numbers = password.match(/[0-9]/)?.length || 0
|
||||||
|
const specials = password.match(/[^a-zA-Z0-9]/)?.length || 0
|
||||||
|
|
||||||
|
// Add 1 every 13 characters
|
||||||
|
score += Math.floor(length / 13)
|
||||||
|
// 1 for every 12 lowercases, 1 for every 12 uppercases, 1 for both
|
||||||
|
score += Math.ceil(lowercases / 12)
|
||||||
|
score += Math.ceil(uppercases / 12)
|
||||||
|
score += lowercases > 0 && uppercases > 0 ? 1 : 0
|
||||||
|
// 1 for every 8 numbers
|
||||||
|
score += Math.ceil(numbers / 8)
|
||||||
|
// 1 for every 2 special characters
|
||||||
|
score += Math.ceil(specials / 2)
|
||||||
|
|
||||||
|
// Divide the result by 3/5 and round up
|
||||||
|
return Math.ceil((score * 3) / 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PassScore: FunctionComponent<{ score: number }> = ({ score }) => {
|
||||||
|
const color = useMemo(() => {
|
||||||
|
if (score <= 1) return "bg-red-500"
|
||||||
|
if (score <= 2) return "bg-orange-500"
|
||||||
|
if (score <= 3) return "bg-yellow-500"
|
||||||
|
else return "bg-green-500"
|
||||||
|
}, [score])
|
||||||
|
const text = useMemo(() => {
|
||||||
|
if (score <= 0) return "Very weak"
|
||||||
|
if (score <= 1) return "Weak"
|
||||||
|
if (score <= 2) return "Okay"
|
||||||
|
if (score <= 3) return "Good"
|
||||||
|
else return "Strong"
|
||||||
|
}, [score])
|
||||||
|
|
||||||
|
const maxCells = 4
|
||||||
|
const litCells = Math.min(score, maxCells)
|
||||||
|
const emptyCells = maxCells - litCells
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex flex-row gap-x-1">
|
||||||
|
{Array.from({ length: litCells }).map((_, i) => (
|
||||||
|
<div key={i} className={`w-1/4 h-1 rounded-full ${color}`} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: emptyCells }).map((_, i) => (
|
||||||
|
<div key={i} className={`w-1/4 h-1 rounded-full bg-slate-200`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-400 font-light text-xs ml-auto">{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserForm: FunctionComponent<{
|
||||||
|
action: "login" | "register"
|
||||||
|
}> = ({ action }) => {
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const from = params.get("from") || "/"
|
||||||
|
|
||||||
|
const opts = actionOptions[action]
|
||||||
|
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const isLoading = navigation.formData?.get("username") != null
|
||||||
|
|
||||||
|
const actionData = useActionData() as { error: string } | undefined
|
||||||
|
|
||||||
|
// Since a user may go from the login page to the signup page, we want to
|
||||||
|
// preserve the `from` query parameter so that we can redirect the user back
|
||||||
|
// to the page they were on before they logged in.
|
||||||
|
const altLink = from ? `${opts.altLink}?from=${from}` : opts.altLink
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const onPasswordChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
const password = event.target.value
|
||||||
|
setPassword(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = useMemo(
|
||||||
|
() => action === "login" || password.length >= 8,
|
||||||
|
[password, action]
|
||||||
|
)
|
||||||
|
const passwordScore = useMemo(() => passwordOMeter(password), [password])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
method="post"
|
||||||
|
replace
|
||||||
|
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8 max-w-min">
|
||||||
|
<span className="text-3xl font-bold text-center mb-4">{opts.title}</span>
|
||||||
|
{actionData && actionData.error ? (
|
||||||
|
<p className="text-red-500 text-center font-medium">
|
||||||
|
{actionData.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<input type="hidden" name="redirectTo" value={from} />
|
||||||
|
<section className="flex flex-col">
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="font-medium text-slate-600 -mb-2 z-10 text-lg">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
id="email"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder=" "
|
||||||
|
minLength={4}
|
||||||
|
maxLength={128}
|
||||||
|
className="p-1 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="font-medium text-slate-600 -mb-2 z-10 text-lg">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={opts.passAutoComplete}
|
||||||
|
name="password"
|
||||||
|
autoComplete={opts.passAutoComplete}
|
||||||
|
aria-describedby={
|
||||||
|
action === "login" ? undefined : "password-constraints"
|
||||||
|
}
|
||||||
|
type="password"
|
||||||
|
placeholder=" "
|
||||||
|
onChange={onPasswordChange}
|
||||||
|
minLength={8}
|
||||||
|
maxLength={128}
|
||||||
|
required
|
||||||
|
className="mb-1 p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{action === "login" ? null : (
|
||||||
|
<>
|
||||||
|
<PassScore score={passwordScore} />
|
||||||
|
<div
|
||||||
|
id="password-constraints"
|
||||||
|
className="mt-2 text-sm text-slate-500">
|
||||||
|
Password must be:
|
||||||
|
<ul className="list-disc list-inside">
|
||||||
|
<li className="list-item">At least 8 characters long</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<Button
|
||||||
|
id={opts.buttonID}
|
||||||
|
type="submit"
|
||||||
|
color="blue"
|
||||||
|
disabled={isLoading || !canSubmit}
|
||||||
|
className="mt-6 px-8 py-3 max-w-fit mx-auto">
|
||||||
|
{isLoading ? opts.buttonLoadingText : opts.buttonText}
|
||||||
|
</Button>
|
||||||
|
<span className="text-slate-500 font-light text-center text-sm">
|
||||||
|
{opts.altLabel + " "}
|
||||||
|
<Link
|
||||||
|
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
|
||||||
|
to={altLink}>
|
||||||
|
{opts.altLinkText}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserForm
|
|
@ -1,13 +1,13 @@
|
||||||
import { LoaderFunction, redirect, useRouteLoaderData } from "react-router-dom"
|
import { LoaderFunction, redirect, useRouteLoaderData } from "react-router-dom"
|
||||||
|
|
||||||
import { User } from "../types"
|
import { User } from "../types"
|
||||||
import fetchAPI from "../util/fetchAPI"
|
import fetchAPI, { FetchAPIResult } from "../util/fetchAPI"
|
||||||
|
|
||||||
export type AuthProviderType = {
|
export type AuthProviderType = {
|
||||||
user: User | null
|
user: User | null
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
signup(username: string, password: string): Promise<boolean>
|
signup(username: string, password: string): Promise<FetchAPIResult<User>>
|
||||||
login(username: string, password: string): Promise<boolean>
|
login(username: string, password: string): Promise<FetchAPIResult<User>>
|
||||||
logout(): Promise<boolean>
|
logout(): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,13 +24,13 @@ async function digestPassword(message: string): Promise<string> {
|
||||||
return hashHex
|
return hashHex
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider: AuthProviderType = {
|
const AuthProvider: AuthProviderType = {
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
async signup(username, plaintextPassword) {
|
async signup(username, plaintextPassword) {
|
||||||
const password = await digestPassword(plaintextPassword)
|
const password = await digestPassword(plaintextPassword)
|
||||||
|
|
||||||
const response = await fetchAPI("/signup", {
|
const response = await fetchAPI<User>("/signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
})
|
})
|
||||||
|
@ -38,7 +38,7 @@ export const AuthProvider: AuthProviderType = {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return this.login(username, plaintextPassword)
|
return this.login(username, plaintextPassword)
|
||||||
}
|
}
|
||||||
return false
|
return response
|
||||||
},
|
},
|
||||||
async login(username, plaintextPassword) {
|
async login(username, plaintextPassword) {
|
||||||
const password = await digestPassword(plaintextPassword)
|
const password = await digestPassword(plaintextPassword)
|
||||||
|
@ -49,13 +49,16 @@ export const AuthProvider: AuthProviderType = {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
this.user = response.data as User
|
this.user = response.data
|
||||||
this.isAuthenticated = true
|
this.isAuthenticated = true
|
||||||
return true
|
return response
|
||||||
}
|
}
|
||||||
return false
|
return response
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
|
if (!this.isAuthenticated) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
const response = await fetchAPI("/logout", {
|
const response = await fetchAPI("/logout", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
|
@ -84,6 +87,8 @@ export const indexLoader: LoaderFunction =
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
AuthProvider.user = response.data as User
|
AuthProvider.user = response.data as User
|
||||||
AuthProvider.isAuthenticated = true
|
AuthProvider.isAuthenticated = true
|
||||||
|
} else {
|
||||||
|
// await AuthProvider.logout()
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
user: AuthProvider.user,
|
user: AuthProvider.user,
|
||||||
|
@ -103,11 +108,15 @@ export const loginAction: LoaderFunction = async ({ request }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await AuthProvider.login(username, password)
|
const r = await AuthProvider.login(username, password)
|
||||||
// Sign in and redirect to the proper destination if successful.
|
// Sign in and redirect to the proper destination if successful.
|
||||||
if (!ok) {
|
if (!r.ok) {
|
||||||
|
// Use a more user-friendly error
|
||||||
|
if (r.error === "Unauthorized") {
|
||||||
|
r.error = "Invalid username or password"
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
error: "Invalid login attempt",
|
error: r.error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,11 +142,11 @@ export const singupAction: LoaderFunction = async ({ request }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await AuthProvider.signup(username, password)
|
const r = await AuthProvider.signup(username, password)
|
||||||
// Sign in and redirect to the proper destination if successful.
|
// Sign in and redirect to the proper destination if successful.
|
||||||
if (!ok) {
|
if (!r.ok) {
|
||||||
return {
|
return {
|
||||||
error: "Invalid signup attempt",
|
error: r.error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
import { LoaderFunction, redirect } from "react-router-dom"
|
||||||
|
|
||||||
|
import Button from "../components/Button"
|
||||||
|
import Header from "../components/Header"
|
||||||
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
|
import { useDelete, useDoubleclickDelete } from "../hooks/useCRUD"
|
||||||
|
import { crudAction } from "../util/action"
|
||||||
|
import fetchAPI from "../util/fetchAPI"
|
||||||
|
|
||||||
|
export const Component: FunctionComponent = () => {
|
||||||
|
const [deleting, del] = useDelete()
|
||||||
|
const [deleteArmed, armDelete] = useDoubleclickDelete(del)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title="Account" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="w-full p-8 border-red-500 border-4 rounded-lg ">
|
||||||
|
<h1 className="text-3xl font-bold text-red-500">Danger Zone</h1>
|
||||||
|
<p className="text-slate-600 font-semibold mt-4">
|
||||||
|
Deleting your account is permanent and cannot be undone.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={armDelete}
|
||||||
|
color="red"
|
||||||
|
className="mt-12 px-8 py-3 max-w-fit">
|
||||||
|
{deleting
|
||||||
|
? "Deleting..."
|
||||||
|
: deleteArmed
|
||||||
|
? "Are you sure?"
|
||||||
|
: "Delete my account"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async (args) => {
|
||||||
|
const resp = await protectedLoader(args)
|
||||||
|
if (resp) return resp
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const action = crudAction({
|
||||||
|
DELETE: async () => {
|
||||||
|
const res = await fetchAPI("/me", {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
return redirect("/lgo")
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
})
|
|
@ -171,6 +171,7 @@ export const Component: FunctionComponent = () => {
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
|
autoFocus
|
||||||
type="url"
|
type="url"
|
||||||
name="url"
|
name="url"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
|
|
@ -1,79 +1,11 @@
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
Link,
|
|
||||||
useActionData,
|
|
||||||
useNavigation,
|
|
||||||
useSearchParams,
|
|
||||||
} from "react-router-dom"
|
|
||||||
|
|
||||||
import Button from "../components/Button"
|
|
||||||
import Header from "../components/Header"
|
import Header from "../components/Header"
|
||||||
|
import UserForm from "../components/UserForm"
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const [params] = useSearchParams()
|
|
||||||
const from = params.get("from") || "/"
|
|
||||||
|
|
||||||
const navigation = useNavigation()
|
|
||||||
const isSigningUp = navigation.formData?.get("username") != null
|
|
||||||
|
|
||||||
const actionData = useActionData() as { error: string } | undefined
|
|
||||||
|
|
||||||
// Since a user may go from the login page to the signup page, we want to
|
|
||||||
// preserve the `from` query parameter so that we can redirect the user back
|
|
||||||
// to the page they were on before they logged in.
|
|
||||||
const signuplink = from ? `/sgn?from=${from}` : "/sgn"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="" />
|
<Header title="" />
|
||||||
<Form
|
<UserForm action="login" />
|
||||||
method="post"
|
|
||||||
replace
|
|
||||||
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8">
|
|
||||||
<span className="text-3xl font-bold text-center mb-4">Log in</span>
|
|
||||||
{actionData && actionData.error ? (
|
|
||||||
<p className="text-red-500 text-center font-medium">
|
|
||||||
{actionData.error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<input type="hidden" name="redirectTo" value={from} />
|
|
||||||
<label className="flex flex-col">
|
|
||||||
<input
|
|
||||||
name="username"
|
|
||||||
minLength={4}
|
|
||||||
maxLength={128}
|
|
||||||
required
|
|
||||||
placeholder="username"
|
|
||||||
className="p-2 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col">
|
|
||||||
<input
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
minLength={8}
|
|
||||||
maxLength={128}
|
|
||||||
required
|
|
||||||
placeholder="password"
|
|
||||||
className="p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
color="blue"
|
|
||||||
disabled={isSigningUp}
|
|
||||||
className="mt-6 px-8 py-3 max-w-fit mx-auto">
|
|
||||||
{isSigningUp ? "Logging in..." : "Log in"}
|
|
||||||
</Button>
|
|
||||||
<span className="text-slate-500 font-light text-center text-sm">
|
|
||||||
{`Don't have an account? `}
|
|
||||||
<Link
|
|
||||||
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
|
|
||||||
to={signuplink}>
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</Form>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,79 +1,11 @@
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
Link,
|
|
||||||
useActionData,
|
|
||||||
useNavigation,
|
|
||||||
useSearchParams,
|
|
||||||
} from "react-router-dom"
|
|
||||||
|
|
||||||
import Button from "../components/Button"
|
|
||||||
import Header from "../components/Header"
|
import Header from "../components/Header"
|
||||||
|
import UserForm from "../components/UserForm"
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const [params] = useSearchParams()
|
|
||||||
const from = params.get("from") || "/"
|
|
||||||
|
|
||||||
const navigation = useNavigation()
|
|
||||||
const isSigningUp = navigation.formData?.get("username") != null
|
|
||||||
|
|
||||||
const actionData = useActionData() as { error: string } | undefined
|
|
||||||
|
|
||||||
// Since a user may go from the login page to the signup page, we want to
|
|
||||||
// preserve the `from` query parameter so that we can redirect the user back
|
|
||||||
// to the page they were on before they logged in.
|
|
||||||
const loginLink = from ? `/lgn?from=${from}` : "/lgn"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="" />
|
<Header title="" />
|
||||||
<Form
|
<UserForm action="register" />
|
||||||
method="post"
|
|
||||||
replace
|
|
||||||
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8">
|
|
||||||
<span className="text-3xl font-bold text-center mb-4">Sign up</span>
|
|
||||||
{actionData && actionData.error ? (
|
|
||||||
<p className="text-red-500 text-center font-medium">
|
|
||||||
{actionData.error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<input type="hidden" name="redirectTo" value={from} />
|
|
||||||
<label className="flex flex-col">
|
|
||||||
<input
|
|
||||||
name="username"
|
|
||||||
minLength={4}
|
|
||||||
maxLength={128}
|
|
||||||
required
|
|
||||||
placeholder="username"
|
|
||||||
className="p-2 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col">
|
|
||||||
<input
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
minLength={8}
|
|
||||||
maxLength={128}
|
|
||||||
required
|
|
||||||
placeholder="password"
|
|
||||||
className="p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
color="blue"
|
|
||||||
disabled={isSigningUp}
|
|
||||||
className="mt-6 px-8 py-3 max-w-fit mx-auto">
|
|
||||||
{isSigningUp ? "Signing up..." : "Sign up"}
|
|
||||||
</Button>
|
|
||||||
<span className="text-slate-500 font-light text-center text-sm">
|
|
||||||
{`Have an account? `}
|
|
||||||
<Link
|
|
||||||
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
|
|
||||||
to={loginLink}>
|
|
||||||
Log in
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</Form>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,6 +65,11 @@ export default createBrowserRouter([
|
||||||
path: "ses",
|
path: "ses",
|
||||||
lazy: () => import("./pages/Sessions"),
|
lazy: () => import("./pages/Sessions"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "account",
|
||||||
|
path: "acc",
|
||||||
|
lazy: () => import("./pages/Account"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: <NotFound />,
|
element: <NotFound />,
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { FetchAPIResult } from "./fetchAPI"
|
||||||
|
|
||||||
export type ActionHandler<T> = (
|
export type ActionHandler<T> = (
|
||||||
formData: FormData
|
formData: FormData
|
||||||
) => Promise<FetchAPIResult<T>>
|
) => Promise<FetchAPIResult<T> | Response>
|
||||||
export type ActionHandlers<T> = {
|
export type ActionHandlers<T> = {
|
||||||
[method in Uppercase<FormMethod>]?: ActionHandler<T>
|
[method in Uppercase<FormMethod>]?: ActionHandler<T>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { GenericItem } from "../types"
|
||||||
|
|
||||||
type ErrorResponse = {
|
type ErrorResponse = {
|
||||||
status: string
|
status: string
|
||||||
error: string
|
error: string
|
||||||
|
@ -14,7 +16,7 @@ export type FetchAPIResult<T> =
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch function that automatically points to the API URL
|
// Fetch function that automatically points to the API URL
|
||||||
export default async function <T>(
|
export default async function <T = GenericItem>(
|
||||||
path: string,
|
path: string,
|
||||||
args: Parameters<typeof fetch>[1] = {}
|
args: Parameters<typeof fetch>[1] = {}
|
||||||
): Promise<FetchAPIResult<T>> {
|
): Promise<FetchAPIResult<T>> {
|
||||||
|
|
|
@ -3,7 +3,8 @@ package userservice
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.maronato.dev/maronato/goshort/internal/config"
|
"git.maronato.dev/maronato/goshort/internal/config"
|
||||||
"git.maronato.dev/maronato/goshort/internal/errs"
|
"git.maronato.dev/maronato/goshort/internal/errs"
|
||||||
|
@ -14,20 +15,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// MinUsernameLength is the minimum length of a username.
|
|
||||||
MinUsernameLength = 4
|
|
||||||
// MaxUsernameLength is the maximum length of a username.
|
|
||||||
MaxUsernameLength = 32
|
|
||||||
// MinPasswordLength is the minimum length of a password.
|
// MinPasswordLength is the minimum length of a password.
|
||||||
MinPasswordLength = 8
|
MinPasswordLength = 8
|
||||||
)
|
// MaxPasswordLength is the maximum length of a password.
|
||||||
|
MaxPasswordLength = 128
|
||||||
var UsernameRegex = regexp.MustCompile(
|
|
||||||
fmt.Sprintf(
|
|
||||||
"^[a-zA-Z0-9_-]{%d,%d}$",
|
|
||||||
MinUsernameLength,
|
|
||||||
MaxUsernameLength,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserService struct {
|
type UserService struct {
|
||||||
|
@ -48,7 +39,7 @@ func (s *UserService) FindUser(ctx context.Context, username string) (*models.Us
|
||||||
// Check if the username is valid
|
// Check if the username is valid
|
||||||
err := UsernameIsValid(username)
|
err := UsernameIsValid(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &models.User{}, fmt.Errorf("could not validate username: %w", err)
|
return nil, fmt.Errorf("could not validate username: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the user from storage
|
// Get the user from storage
|
||||||
|
@ -63,18 +54,18 @@ func (s *UserService) FindUser(ctx context.Context, username string) (*models.Us
|
||||||
func (s *UserService) CreateUser(ctx context.Context, user *models.User) (*models.User, error) {
|
func (s *UserService) CreateUser(ctx context.Context, user *models.User) (*models.User, error) {
|
||||||
// Check for disabled registration
|
// Check for disabled registration
|
||||||
if s.disableRegistration {
|
if s.disableRegistration {
|
||||||
return &models.User{}, errs.ErrRegistrationDisabled
|
return nil, errs.ErrRegistrationDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user is valid
|
// Check if the user is valid
|
||||||
err := UserIsValid(user)
|
err := UserIsValid(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &models.User{}, fmt.Errorf("could not validate user: %w", err)
|
return nil, fmt.Errorf("could not validate user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newUser, err := s.db.CreateUser(ctx, user)
|
newUser, err := s.db.CreateUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &models.User{}, fmt.Errorf("could not create user in storage: %w", err)
|
return nil, fmt.Errorf("could not create user in storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return newUser, nil
|
return newUser, nil
|
||||||
|
@ -93,23 +84,26 @@ func (s *UserService) AuthenticateUser(ctx context.Context, username, password s
|
||||||
// Get user from storage
|
// Get user from storage
|
||||||
user, err = s.FindUser(ctx, username)
|
user, err = s.FindUser(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Even if the user does not exist, hash a password to waste time
|
// Waste time if the user is not found
|
||||||
// and not give away wether or not the user exists.
|
// to mitigate timing attacks
|
||||||
_, _ = s.hasher.Hash("r4ndom_passw0rd")
|
wasteErr := s.hasher.WasteTime()
|
||||||
|
if wasteErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to authenticate: %w", wasteErr)
|
||||||
|
}
|
||||||
|
|
||||||
return &models.User{}, fmt.Errorf("failed to find user: %w", err)
|
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to authenticate
|
// Try to authenticate
|
||||||
if password == "" || user.GetPasswordHash() == "" {
|
if password == "" || user.GetPasswordHash() == "" {
|
||||||
return &models.User{}, errs.ErrFailedAuthentication
|
return nil, errs.ErrFailedAuthentication
|
||||||
}
|
}
|
||||||
|
|
||||||
match, err := s.hasher.Verify(password, user.GetPasswordHash())
|
match, err := s.hasher.Verify(password, user.GetPasswordHash())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &models.User{}, fmt.Errorf("failed to authenticate user: %w", err)
|
return nil, fmt.Errorf("failed to authenticate user: %w", err)
|
||||||
} else if !match {
|
} else if !match {
|
||||||
return &models.User{}, errs.ErrFailedAuthentication
|
return nil, errs.ErrFailedAuthentication
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success
|
// Success
|
||||||
|
@ -118,8 +112,8 @@ func (s *UserService) AuthenticateUser(ctx context.Context, username, password s
|
||||||
|
|
||||||
func (s *UserService) SetPassword(_ context.Context, user *models.User, newPassword string) error {
|
func (s *UserService) SetPassword(_ context.Context, user *models.User, newPassword string) error {
|
||||||
// Check if the password is valid
|
// Check if the password is valid
|
||||||
if len(newPassword) < MinPasswordLength {
|
if len(newPassword) < MinPasswordLength || len(newPassword) > MaxPasswordLength {
|
||||||
return fmt.Errorf("password must be at least %d characters long: %w", MinPasswordLength, errs.ErrInvalidUser)
|
return fmt.Errorf("password must be between %d and %d characters long: %w", MinPasswordLength, MaxPasswordLength, errs.ErrInvalidUser)
|
||||||
}
|
}
|
||||||
// Set the new password
|
// Set the new password
|
||||||
err := user.SetPassword(s.hasher, newPassword)
|
err := user.SetPassword(s.hasher, newPassword)
|
||||||
|
@ -131,11 +125,13 @@ func (s *UserService) SetPassword(_ context.Context, user *models.User, newPassw
|
||||||
}
|
}
|
||||||
|
|
||||||
func UsernameIsValid(username string) error {
|
func UsernameIsValid(username string) error {
|
||||||
if !UsernameRegex.MatchString(username) {
|
if !strings.Contains(username, "<") {
|
||||||
return errs.Errorf(fmt.Sprintf("username must match %s", UsernameRegex), errs.ErrInvalidUser)
|
if _, err := mail.ParseAddress(username); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return errs.Errorf("username must be a valid email address", errs.ErrInvalidUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserIsValid(user *models.User) error {
|
func UserIsValid(user *models.User) error {
|
||||||
|
|
|
@ -401,11 +401,37 @@ func (s *BunStorage) CreateUser(ctx context.Context, user *models.User) (*models
|
||||||
func (s *BunStorage) DeleteUser(ctx context.Context, user *models.User) error {
|
func (s *BunStorage) DeleteUser(ctx context.Context, user *models.User) error {
|
||||||
// Delete user in transaction
|
// Delete user in transaction
|
||||||
err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||||
|
// Delete user short logs
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*ShortLogModel)(nil)).
|
||||||
|
Where("short_id IN (?)", tx.NewSelect().
|
||||||
|
Model((*ShortModel)(nil)).
|
||||||
|
Column("id").
|
||||||
|
Where("user_id = ?", user.ID)).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errs.Errorf("failed to delete short logs from user's shorts", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete user shorts
|
// Delete user shorts
|
||||||
err := deleteUserShorts(ctx, tx, user)
|
_, err = withShortDeleteUpdates(
|
||||||
|
tx.NewUpdate().
|
||||||
|
Model((*ShortModel)(nil)).
|
||||||
|
Where("user_id = ?", user.ID),
|
||||||
|
).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errs.Errorf("failed to delete user shorts", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user tokens
|
||||||
|
_, err = tx.NewDelete().
|
||||||
|
Model((*TokenModel)(nil)).
|
||||||
|
Where("user_id = ?", user.ID).
|
||||||
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.Errorf("failed to delete user", err)
|
return errs.Errorf("failed to delete user", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
_, err = tx.NewDelete().
|
_, err = tx.NewDelete().
|
||||||
Model(user).
|
Model(user).
|
||||||
|
@ -585,19 +611,6 @@ func withShortDeleteUpdates(q *bun.UpdateQuery) *bun.UpdateQuery {
|
||||||
Set("user_id = ?", nil)
|
Set("user_id = ?", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteUserShorts(ctx context.Context, db bun.IDB, user *models.User) error {
|
|
||||||
_, err := withShortDeleteUpdates(
|
|
||||||
db.NewUpdate().
|
|
||||||
Model((*ShortModel)(nil)).
|
|
||||||
Where("user_id = ?", user.ID),
|
|
||||||
).Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errs.Errorf("failed to delete user shorts", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createNewID(ctx context.Context, db bun.IDB, table string) (string, error) {
|
func createNewID(ctx context.Context, db bun.IDB, table string) (string, error) {
|
||||||
var newID string
|
var newID string
|
||||||
|
|
||||||
|
|
|
@ -688,6 +688,29 @@ func ITestDeleteUser(t *testing.T, stg storage.Storage) {
|
||||||
_ = baseUser.SetPassword(bh, "mypassword")
|
_ = baseUser.SetPassword(bh, "mypassword")
|
||||||
|
|
||||||
user, _ := stg.CreateUser(ctx, baseUser)
|
user, _ := stg.CreateUser(ctx, baseUser)
|
||||||
|
short1, _ := stg.CreateShort(ctx, &models.Short{
|
||||||
|
Name: "myshort",
|
||||||
|
URL: "https://example.com",
|
||||||
|
UserID: &user.ID,
|
||||||
|
})
|
||||||
|
short2, _ := stg.CreateShort(ctx, &models.Short{
|
||||||
|
Name: "myshort2",
|
||||||
|
URL: "https://example.com",
|
||||||
|
UserID: &user.ID,
|
||||||
|
})
|
||||||
|
_ = stg.CreateShortLog(ctx, &models.ShortLog{
|
||||||
|
ShortID: short1.ID,
|
||||||
|
})
|
||||||
|
_ = stg.CreateShortLog(ctx, &models.ShortLog{
|
||||||
|
ShortID: short1.ID,
|
||||||
|
})
|
||||||
|
_ = stg.CreateShortLog(ctx, &models.ShortLog{
|
||||||
|
ShortID: short2.ID,
|
||||||
|
})
|
||||||
|
_, _ = stg.CreateToken(ctx, &models.Token{
|
||||||
|
Value: "myvalue",
|
||||||
|
UserID: &user.ID,
|
||||||
|
})
|
||||||
|
|
||||||
found, _ := stg.FindUser(ctx, user.Username)
|
found, _ := stg.FindUser(ctx, user.Username)
|
||||||
assert.NotNil(t, found, "Should find the user")
|
assert.NotNil(t, found, "Should find the user")
|
||||||
|
@ -699,6 +722,16 @@ func ITestDeleteUser(t *testing.T, stg storage.Storage) {
|
||||||
assert.ErrorIs(t, err, errs.ErrUserDoesNotExist, "Should return an error when finding the user")
|
assert.ErrorIs(t, err, errs.ErrUserDoesNotExist, "Should return an error when finding the user")
|
||||||
assert.Nil(t, found, "Should not find the user")
|
assert.Nil(t, found, "Should not find the user")
|
||||||
|
|
||||||
|
shorts, _ := stg.ListShorts(ctx, user)
|
||||||
|
assert.Len(t, shorts, 0, "Should not have any shorts")
|
||||||
|
|
||||||
|
shortLogs1, _ := stg.ListShortLogs(ctx, short1)
|
||||||
|
shortLogs2, _ := stg.ListShortLogs(ctx, short2)
|
||||||
|
assert.Len(t, append(shortLogs1, shortLogs2...), 0, "Should not have any short logs")
|
||||||
|
|
||||||
|
tokens, _ := stg.ListTokens(ctx, user)
|
||||||
|
assert.Len(t, tokens, 0, "Should not have any tokens")
|
||||||
|
|
||||||
err = stg.DeleteUser(ctx, user)
|
err = stg.DeleteUser(ctx, user)
|
||||||
assert.Nil(t, err, "Should not return an error when deleting a deleted user")
|
assert.Nil(t, err, "Should not return an error when deleting a deleted user")
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultMemory uint32 = 512 * 1024 // 512 MiB
|
defaultMemory uint32 = 512 * 1024 // 512 MiB
|
||||||
defaultIterations uint32 = 1
|
defaultIterations uint32 = 2
|
||||||
defaultParallelism uint8 = 8
|
defaultParallelism uint8 = 8
|
||||||
defaultSaltLength uint32 = 16
|
defaultSaltLength uint32 = 16
|
||||||
defaultKeyLength uint32 = 32
|
defaultKeyLength uint32 = 32
|
||||||
|
@ -85,6 +85,18 @@ func (h *ArgonHasher) Verify(password, encodedHash string) (match bool, err erro
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ArgonHasher) WasteTime() error {
|
||||||
|
// Const hash of "r4ndom_passw0rd"
|
||||||
|
const hash = "argon2id$v=19,m=524288,t=2,p=8$yK93NxxW12tA3vE+FnbdTQ$U2cDKipQ/X3nRi0saQhUiSoLofBBWqpoJTIQyhsCJ3s"
|
||||||
|
|
||||||
|
_, err := h.Verify("oth3r_passw0rd", hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not waste time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ArgonHasher) decodeHash(encodedHash string) (p argonParams, salt, hash []byte, err error) { //nolint:cyclop // Not refactoring this
|
func (h *ArgonHasher) decodeHash(encodedHash string) (p argonParams, salt, hash []byte, err error) { //nolint:cyclop // Not refactoring this
|
||||||
algorithm, strSalt, strHash, params, err := passwords.DecodePasswordHash(encodedHash)
|
algorithm, strSalt, strHash, params, err := passwords.DecodePasswordHash(encodedHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -45,3 +45,11 @@ func TestArgonVerify(t *testing.T) {
|
||||||
|
|
||||||
assert.ErrorIs(t, err, passwords.ErrInvalidHash, "ArgonVerify should return an error for an invalid hash")
|
assert.ErrorIs(t, err, passwords.ErrInvalidHash, "ArgonVerify should return an error for an invalid hash")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestArgonWasteTime(t *testing.T) {
|
||||||
|
ah := NewArgonHasher()
|
||||||
|
|
||||||
|
err := ah.WasteTime()
|
||||||
|
|
||||||
|
assert.Nil(t, err, "WasteTime should not return an error")
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultCost = 13
|
||||||
|
|
||||||
type BcryptHasher struct {
|
type BcryptHasher struct {
|
||||||
passwords.PasswordHasher
|
passwords.PasswordHasher
|
||||||
cost int
|
cost int
|
||||||
|
@ -15,7 +17,7 @@ type BcryptHasher struct {
|
||||||
|
|
||||||
func NewBcryptHasher() *BcryptHasher {
|
func NewBcryptHasher() *BcryptHasher {
|
||||||
return &BcryptHasher{
|
return &BcryptHasher{
|
||||||
cost: bcrypt.DefaultCost,
|
cost: defaultCost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,3 +45,15 @@ func (h *BcryptHasher) Verify(password, encodedHash string) (bool, error) {
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *BcryptHasher) WasteTime() error {
|
||||||
|
// Const hash of "r4ndom_passw0rd"
|
||||||
|
const hash = "$2a$13$IVy6l.btAXiQUNjV40nai.1HV1VL.4DvoBDrTSE7b16CioMe1i3eG"
|
||||||
|
|
||||||
|
_, err := h.Verify("oth3r_passw0rd", hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not waste time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -45,3 +45,11 @@ func TestBcryptVerify(t *testing.T) {
|
||||||
|
|
||||||
assert.ErrorIs(t, err, passwords.ErrInvalidHash, "BcryptVerify should return an error for an invalid hash")
|
assert.ErrorIs(t, err, passwords.ErrInvalidHash, "BcryptVerify should return an error for an invalid hash")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBcryptWasteTime(t *testing.T) {
|
||||||
|
bh := NewBcryptHasher()
|
||||||
|
|
||||||
|
err := bh.WasteTime()
|
||||||
|
|
||||||
|
assert.Nil(t, err, "WasteTime should not return an error")
|
||||||
|
}
|
||||||
|
|
|
@ -3,4 +3,5 @@ package passwords
|
||||||
type PasswordHasher interface {
|
type PasswordHasher interface {
|
||||||
Hash(password string) (string, error)
|
Hash(password string) (string, error)
|
||||||
Verify(password, encodedHash string) (bool, error)
|
Verify(password, encodedHash string) (bool, error)
|
||||||
|
WasteTime() error
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user