added more tests and ui updates
All checks were successful
Build / build (push) Successful in 11m48s

This commit is contained in:
Gustavo Maronato 2023-08-29 23:38:52 -03:00
parent 4d6600b383
commit 6498ac56d9
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
20 changed files with 466 additions and 225 deletions

View File

@ -14,25 +14,6 @@ const App: FunctionComponent = () => {
<Navbar />
<Container>
<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">
<Outlet />
</div>

View File

@ -14,7 +14,7 @@ type DefaultButtonProps = DetailedHTMLProps<
const Button: FunctionComponent<
Pick<
DefaultButtonProps,
"className" | "onClick" | "type" | "disabled" | "children"
"className" | "onClick" | "type" | "disabled" | "children" | "id"
> & {
color?: "blue" | "red" | "green"
}

View File

@ -23,7 +23,7 @@ export default function Navbar() {
{ name: "Tokens", href: "tkn" },
{ name: "Sessions", href: "ses" },
]
const unauthed = [{ name: "Signup", href: "sgn" }]
const unauthed = [{ name: "Register", href: "sgn" }]
return isAuthenticated ? authed : unauthed
}, [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">
<span className="absolute -inset-1.5" />
<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">
<ChevronDownIcon
className="h-4 w-4 ml-1"
@ -89,7 +91,24 @@ export default function Navbar() {
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
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>
{({ active }) => (
<Link

View File

@ -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

View File

@ -1,13 +1,13 @@
import { LoaderFunction, redirect, useRouteLoaderData } from "react-router-dom"
import { User } from "../types"
import fetchAPI from "../util/fetchAPI"
import fetchAPI, { FetchAPIResult } from "../util/fetchAPI"
export type AuthProviderType = {
user: User | null
isAuthenticated: boolean
signup(username: string, password: string): Promise<boolean>
login(username: string, password: string): Promise<boolean>
signup(username: string, password: string): Promise<FetchAPIResult<User>>
login(username: string, password: string): Promise<FetchAPIResult<User>>
logout(): Promise<boolean>
}
@ -24,13 +24,13 @@ async function digestPassword(message: string): Promise<string> {
return hashHex
}
export const AuthProvider: AuthProviderType = {
const AuthProvider: AuthProviderType = {
user: null,
isAuthenticated: false,
async signup(username, plaintextPassword) {
const password = await digestPassword(plaintextPassword)
const response = await fetchAPI("/signup", {
const response = await fetchAPI<User>("/signup", {
method: "POST",
body: JSON.stringify({ username, password }),
})
@ -38,7 +38,7 @@ export const AuthProvider: AuthProviderType = {
if (response.ok) {
return this.login(username, plaintextPassword)
}
return false
return response
},
async login(username, plaintextPassword) {
const password = await digestPassword(plaintextPassword)
@ -49,13 +49,16 @@ export const AuthProvider: AuthProviderType = {
})
if (response.ok) {
this.user = response.data as User
this.user = response.data
this.isAuthenticated = true
return true
return response
}
return false
return response
},
async logout() {
if (!this.isAuthenticated) {
return true
}
const response = await fetchAPI("/logout", {
method: "POST",
})
@ -84,6 +87,8 @@ export const indexLoader: LoaderFunction =
if (response.ok) {
AuthProvider.user = response.data as User
AuthProvider.isAuthenticated = true
} else {
// await AuthProvider.logout()
}
return {
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.
if (!ok) {
if (!r.ok) {
// Use a more user-friendly error
if (r.error === "Unauthorized") {
r.error = "Invalid username or password"
}
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.
if (!ok) {
if (!r.ok) {
return {
error: "Invalid signup attempt",
error: r.error,
}
}

View File

@ -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
},
})

View File

@ -171,6 +171,7 @@ export const Component: FunctionComponent = () => {
</span>
</span>
<input
autoFocus
type="url"
name="url"
autoComplete="off"

View File

@ -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 UserForm from "../components/UserForm"
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 (
<>
<Header title="" />
<Form
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>
<UserForm action="login" />
</>
)
}

View File

@ -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 UserForm from "../components/UserForm"
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 (
<>
<Header title="" />
<Form
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>
<UserForm action="register" />
</>
)
}

View File

@ -65,6 +65,11 @@ export default createBrowserRouter([
path: "ses",
lazy: () => import("./pages/Sessions"),
},
{
id: "account",
path: "acc",
lazy: () => import("./pages/Account"),
},
{
path: "*",
element: <NotFound />,

View File

@ -6,7 +6,7 @@ import { FetchAPIResult } from "./fetchAPI"
export type ActionHandler<T> = (
formData: FormData
) => Promise<FetchAPIResult<T>>
) => Promise<FetchAPIResult<T> | Response>
export type ActionHandlers<T> = {
[method in Uppercase<FormMethod>]?: ActionHandler<T>
}

View File

@ -1,3 +1,5 @@
import { GenericItem } from "../types"
type ErrorResponse = {
status: string
error: string
@ -14,7 +16,7 @@ export type FetchAPIResult<T> =
}
// Fetch function that automatically points to the API URL
export default async function <T>(
export default async function <T = GenericItem>(
path: string,
args: Parameters<typeof fetch>[1] = {}
): Promise<FetchAPIResult<T>> {

View File

@ -3,7 +3,8 @@ package userservice
import (
"context"
"fmt"
"regexp"
"net/mail"
"strings"
"git.maronato.dev/maronato/goshort/internal/config"
"git.maronato.dev/maronato/goshort/internal/errs"
@ -14,20 +15,10 @@ import (
)
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 = 8
)
var UsernameRegex = regexp.MustCompile(
fmt.Sprintf(
"^[a-zA-Z0-9_-]{%d,%d}$",
MinUsernameLength,
MaxUsernameLength,
),
// MaxPasswordLength is the maximum length of a password.
MaxPasswordLength = 128
)
type UserService struct {
@ -48,7 +39,7 @@ func (s *UserService) FindUser(ctx context.Context, username string) (*models.Us
// Check if the username is valid
err := UsernameIsValid(username)
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
@ -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) {
// Check for disabled registration
if s.disableRegistration {
return &models.User{}, errs.ErrRegistrationDisabled
return nil, errs.ErrRegistrationDisabled
}
// Check if the user is valid
err := UserIsValid(user)
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)
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
@ -93,23 +84,26 @@ func (s *UserService) AuthenticateUser(ctx context.Context, username, password s
// Get user from storage
user, err = s.FindUser(ctx, username)
if err != nil {
// Even if the user does not exist, hash a password to waste time
// and not give away wether or not the user exists.
_, _ = s.hasher.Hash("r4ndom_passw0rd")
// Waste time if the user is not found
// to mitigate timing attacks
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
if password == "" || user.GetPasswordHash() == "" {
return &models.User{}, errs.ErrFailedAuthentication
return nil, errs.ErrFailedAuthentication
}
match, err := s.hasher.Verify(password, user.GetPasswordHash())
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 {
return &models.User{}, errs.ErrFailedAuthentication
return nil, errs.ErrFailedAuthentication
}
// 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 {
// Check if the password is valid
if len(newPassword) < MinPasswordLength {
return fmt.Errorf("password must be at least %d characters long: %w", MinPasswordLength, errs.ErrInvalidUser)
if len(newPassword) < MinPasswordLength || len(newPassword) > MaxPasswordLength {
return fmt.Errorf("password must be between %d and %d characters long: %w", MinPasswordLength, MaxPasswordLength, errs.ErrInvalidUser)
}
// Set the new password
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 {
if !UsernameRegex.MatchString(username) {
return errs.Errorf(fmt.Sprintf("username must match %s", UsernameRegex), errs.ErrInvalidUser)
if !strings.Contains(username, "<") {
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 {

View File

@ -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 {
// Delete user in transaction
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
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 {
return errs.Errorf("failed to delete user", err)
}
// Delete user
_, err = tx.NewDelete().
Model(user).
@ -585,19 +611,6 @@ func withShortDeleteUpdates(q *bun.UpdateQuery) *bun.UpdateQuery {
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) {
var newID string

View File

@ -688,6 +688,29 @@ func ITestDeleteUser(t *testing.T, stg storage.Storage) {
_ = baseUser.SetPassword(bh, "mypassword")
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)
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.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)
assert.Nil(t, err, "Should not return an error when deleting a deleted user")
}

View File

@ -10,7 +10,7 @@ import (
const (
defaultMemory uint32 = 512 * 1024 // 512 MiB
defaultIterations uint32 = 1
defaultIterations uint32 = 2
defaultParallelism uint8 = 8
defaultSaltLength uint32 = 16
defaultKeyLength uint32 = 32
@ -85,6 +85,18 @@ func (h *ArgonHasher) Verify(password, encodedHash string) (match bool, err erro
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
algorithm, strSalt, strHash, params, err := passwords.DecodePasswordHash(encodedHash)
if err != nil {

View File

@ -45,3 +45,11 @@ func TestArgonVerify(t *testing.T) {
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")
}

View File

@ -8,6 +8,8 @@ import (
"golang.org/x/crypto/bcrypt"
)
const defaultCost = 13
type BcryptHasher struct {
passwords.PasswordHasher
cost int
@ -15,7 +17,7 @@ type BcryptHasher struct {
func NewBcryptHasher() *BcryptHasher {
return &BcryptHasher{
cost: bcrypt.DefaultCost,
cost: defaultCost,
}
}
@ -43,3 +45,15 @@ func (h *BcryptHasher) Verify(password, encodedHash string) (bool, error) {
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
}

View File

@ -45,3 +45,11 @@ func TestBcryptVerify(t *testing.T) {
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")
}

View File

@ -3,4 +3,5 @@ package passwords
type PasswordHasher interface {
Hash(password string) (string, error)
Verify(password, encodedHash string) (bool, error)
WasteTime() error
}