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

View File

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

View File

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

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 { 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,
} }
} }

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>
</span> </span>
<input <input
autoFocus
type="url" type="url"
name="url" name="url"
autoComplete="off" 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 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>
</> </>
) )
} }

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 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>
</> </>
) )
} }

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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") 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" "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
}

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") 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 { 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
} }