From 6498ac56d9aa87372443a5ff43e80999e7f62404 Mon Sep 17 00:00:00 2001 From: Gustavo Maronato Date: Tue, 29 Aug 2023 23:38:52 -0300 Subject: [PATCH] added more tests and ui updates --- frontend/src/App.tsx | 19 -- frontend/src/components/Button.tsx | 2 +- frontend/src/components/Navbar.tsx | 25 +- frontend/src/components/UserForm.tsx | 217 ++++++++++++++++++ frontend/src/hooks/useAuth.tsx | 39 ++-- frontend/src/pages/Account.tsx | 58 +++++ frontend/src/pages/Index.tsx | 1 + frontend/src/pages/Login.tsx | 72 +----- frontend/src/pages/Signup.tsx | 72 +----- frontend/src/router.tsx | 5 + frontend/src/util/action.ts | 2 +- frontend/src/util/fetchAPI.ts | 4 +- internal/service/user/userservice.go | 54 ++--- internal/storage/bun/storage.go | 41 ++-- internal/storage/testing/storagetesting.go | 33 +++ internal/util/passwords/argon/hasher.go | 14 +- internal/util/passwords/argon/hasher_test.go | 8 + internal/util/passwords/bcrypt/hasher.go | 16 +- internal/util/passwords/bcrypt/hasher_test.go | 8 + internal/util/passwords/hasher.go | 1 + 20 files changed, 466 insertions(+), 225 deletions(-) create mode 100644 frontend/src/components/UserForm.tsx create mode 100644 frontend/src/pages/Account.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1f5395..ff7d340 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,25 +14,6 @@ const App: FunctionComponent = () => {
- {/*
-
-
-
- {isAuthenticated && ( - <> -
-
- {isAuthenticated ||
-
*/}
diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 41c8d38..38f0fc8 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -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" } diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 0692bc4..784c032 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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() { Open user menu - {user?.username} + + {user?.username.split("@")[0]} + - + + +
+ {user?.username} +
+
+ + {({ active }) => ( + + Account + + )} + {({ active }) => ( { + 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 ( +
+
+ {Array.from({ length: litCells }).map((_, i) => ( +
+ ))} + {Array.from({ length: emptyCells }).map((_, i) => ( +
+ ))} +
+ {text} +
+ ) +} + +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 = (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 ( +
+ {opts.title} + {actionData && actionData.error ? ( +

+ {actionData.error} +

+ ) : null} + +
+ + +
+
+ + + + {action === "login" ? null : ( + <> + +
+ Password must be: +
    +
  • At least 8 characters long
  • +
+
+ + )} +
+ + + {opts.altLabel + " "} + + {opts.altLinkText} + + +
+ ) +} + +export default UserForm diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 9138900..e05f283 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -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 - login(username: string, password: string): Promise + signup(username: string, password: string): Promise> + login(username: string, password: string): Promise> logout(): Promise } @@ -24,13 +24,13 @@ async function digestPassword(message: string): Promise { 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("/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, } } diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx new file mode 100644 index 0000000..107d989 --- /dev/null +++ b/frontend/src/pages/Account.tsx @@ -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 ( + <> +
+
+
+

Danger Zone

+

+ Deleting your account is permanent and cannot be undone. +

+ +
+
+ + ) +} + +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 + }, +}) diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index 4e1eec8..46682b6 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -171,6 +171,7 @@ export const Component: FunctionComponent = () => {
-
- Log in - {actionData && actionData.error ? ( -

- {actionData.error} -

- ) : null} - - - - - - {`Don't have an account? `} - - Sign up - - -
+ ) } diff --git a/frontend/src/pages/Signup.tsx b/frontend/src/pages/Signup.tsx index 711f03a..f0c7971 100644 --- a/frontend/src/pages/Signup.tsx +++ b/frontend/src/pages/Signup.tsx @@ -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 ( <>
-
- Sign up - {actionData && actionData.error ? ( -

- {actionData.error} -

- ) : null} - - - - - - {`Have an account? `} - - Log in - - -
+ ) } diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 3345c24..86a0ea4 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -65,6 +65,11 @@ export default createBrowserRouter([ path: "ses", lazy: () => import("./pages/Sessions"), }, + { + id: "account", + path: "acc", + lazy: () => import("./pages/Account"), + }, { path: "*", element: , diff --git a/frontend/src/util/action.ts b/frontend/src/util/action.ts index 206c989..63ffae9 100644 --- a/frontend/src/util/action.ts +++ b/frontend/src/util/action.ts @@ -6,7 +6,7 @@ import { FetchAPIResult } from "./fetchAPI" export type ActionHandler = ( formData: FormData -) => Promise> +) => Promise | Response> export type ActionHandlers = { [method in Uppercase]?: ActionHandler } diff --git a/frontend/src/util/fetchAPI.ts b/frontend/src/util/fetchAPI.ts index f88fefb..16d5516 100644 --- a/frontend/src/util/fetchAPI.ts +++ b/frontend/src/util/fetchAPI.ts @@ -1,3 +1,5 @@ +import { GenericItem } from "../types" + type ErrorResponse = { status: string error: string @@ -14,7 +16,7 @@ export type FetchAPIResult = } // Fetch function that automatically points to the API URL -export default async function ( +export default async function ( path: string, args: Parameters[1] = {} ): Promise> { diff --git a/internal/service/user/userservice.go b/internal/service/user/userservice.go index 323c641..0040483 100644 --- a/internal/service/user/userservice.go +++ b/internal/service/user/userservice.go @@ -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 { diff --git a/internal/storage/bun/storage.go b/internal/storage/bun/storage.go index b56e71f..f1d3300 100644 --- a/internal/storage/bun/storage.go +++ b/internal/storage/bun/storage.go @@ -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 diff --git a/internal/storage/testing/storagetesting.go b/internal/storage/testing/storagetesting.go index 2d8dc00..33dc6c2 100644 --- a/internal/storage/testing/storagetesting.go +++ b/internal/storage/testing/storagetesting.go @@ -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") } diff --git a/internal/util/passwords/argon/hasher.go b/internal/util/passwords/argon/hasher.go index 8cd1fe7..129edf9 100644 --- a/internal/util/passwords/argon/hasher.go +++ b/internal/util/passwords/argon/hasher.go @@ -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 { diff --git a/internal/util/passwords/argon/hasher_test.go b/internal/util/passwords/argon/hasher_test.go index b836b1d..23773d3 100644 --- a/internal/util/passwords/argon/hasher_test.go +++ b/internal/util/passwords/argon/hasher_test.go @@ -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") +} diff --git a/internal/util/passwords/bcrypt/hasher.go b/internal/util/passwords/bcrypt/hasher.go index d86c306..8aa0634 100644 --- a/internal/util/passwords/bcrypt/hasher.go +++ b/internal/util/passwords/bcrypt/hasher.go @@ -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 +} diff --git a/internal/util/passwords/bcrypt/hasher_test.go b/internal/util/passwords/bcrypt/hasher_test.go index 728e909..56a3cdc 100644 --- a/internal/util/passwords/bcrypt/hasher_test.go +++ b/internal/util/passwords/bcrypt/hasher_test.go @@ -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") +} diff --git a/internal/util/passwords/hasher.go b/internal/util/passwords/hasher.go index 7f3b9ba..015464f 100644 --- a/internal/util/passwords/hasher.go +++ b/internal/util/passwords/hasher.go @@ -3,4 +3,5 @@ package passwords type PasswordHasher interface { Hash(password string) (string, error) Verify(password, encodedHash string) (bool, error) + WasteTime() error }