Vili/routing register (#66)

* route to backend (hello world)

* add route for register

* fix lint

* fix routing

* connect to db

* add hashing with salt

* add error handling

* add unit tests for register

* fix errors

* axel/fix-import

* fix register

* update response to use error instead of message

* fix tests

* fix register

* testfactory user toLowerCase()

* fix migrate

* fix register style

* fix layout

---------

Co-authored-by: DoebelnAtz <axel.ej.adlercreutz@gmail.com>
This commit is contained in:
Vili-Ilari Kaisto
2025-02-27 12:57:46 +02:00
committed by GitHub
co-authored by DoebelnAtz
parent 4d34590cd6
commit e8639db6c0
9 changed files with 207 additions and 21 deletions
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS salt text NOT NULL;
+2 -1
View File
@@ -83,7 +83,8 @@ CREATE TABLE public.users (
email text UNIQUE NOT NULL,
password_hash text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
updated_at timestamp with time zone NOT NULL DEFAULT now(),
salt text NOT NULL
);
CREATE TRIGGER updated_at BEFORE UPDATE ON public.users FOR EACH ROW EXECUTE FUNCTION updated_at();
@@ -0,0 +1,46 @@
import { DB } from "../../../../backend/db";
import { hashPassword } from "@/backend/auth/hash";
import { emailRegex } from "@/shared/const";
export async function POST(request) {
const json = await request.json();
const email = json["email"].toLowerCase().trim();
const password = json["password"];
const { salt, hashedPassword } = await hashPassword(password);
const existingUser = await DB.pool("SELECT * FROM users WHERE email = $1", [
email,
]);
if (existingUser.rows.length > 0) {
return Response.json(
{ error: "Konto ist bereits vorhanden." },
{ status: 409 }
);
}
if (email === "" || password === "") {
return Response.json(
{ error: "E-Mail und Passwort sind erforderlich." },
{ status: 400 }
);
}
if (emailRegex.test(email) === false) {
return Response.json(
{ error: "Ungültige E-Mail-Adresse." },
{ status: 422 }
);
}
if (password.length < 8 || password.length > 64) {
return Response.json(
{ error: "Das Passwort muss mindestens 8 Zeichen lang sein." },
{ status: 422 }
);
}
await DB.pool(
"INSERT INTO users (email, password_hash, salt) VALUES ($1, $2, $3)",
[email, hashedPassword, salt]
);
return Response.json({
status: 200,
message: "Account created.",
});
}
+25 -18
View File
@@ -1,48 +1,55 @@
"use client";
import { useState } from "react";
import { useRequest } from "@/shared/hooks/useRequest";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function Register() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState(null);
const [error, setError] = useState("");
const router = useRouter();
const makeRequest = useRequest();
const handleSubmit = async (event) => {
event.preventDefault();
if (email === "" || password === "") {
setError(true);
setSubmitted(false);
} else {
try {
const data = await makeRequest("/auth/register", { email, password });
setSubmitted(true);
setError(false);
setTimeout(() => {
window.location.href = "/auth/login";
}, 1500);
router.push("/auth/login");
}, 2000);
setError("");
} catch (error) {
setError(error.message);
setSubmitted(false);
}
};
const successMessage = () => {
return submitted ? (
<div className="success-message">
Benutzer {email} erfolgreich registriert
return (
<div className="success">
<h1>Benutzer erfolgreich registriert</h1>
</div>
) : null;
);
};
const errorMessage = () => {
return error ? (
<div className="error-message">Bitte alle Felder ausfüllen</div>
) : null;
return (
<div className="error">
<h1>{error}</h1>
</div>
);
};
return (
<>
<h1 className="auth-title">Registrieren</h1>
<div className="messages">
{errorMessage()}
{successMessage()}
{!!error && errorMessage()}
{submitted && successMessage()}
</div>
<form onSubmit={handleSubmit}>
+13
View File
@@ -0,0 +1,13 @@
const crypto = require("crypto");
export async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
const hashedPassword = derivedKey.toString("hex");
resolve({ salt, hashedPassword });
});
});
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useTestDatabase } from "./testdb";
import { POST } from "../../app/api/auth/register/route";
import { DB } from "../db";
import { TestFactory } from "./testfactory";
import { useTestRequest } from "@/backend/test/mock-request";
describe("POST /register", () => {
useTestDatabase();
it("should return 409 if account already exists", async () => {
const user = await TestFactory.user();
const { mockPost } = useTestRequest();
const request = mockPost("@/api/auth/register", {
email: user.email,
password: user.password_hash,
});
const response = await POST(request);
const result = await response.json();
expect(response.status).toBe(409);
expect(result.error).toBe("Konto ist bereits vorhanden.");
});
it("should return 400 if email and password are empty", async () => {
const email = "";
const password = "";
const { mockPost } = useTestRequest();
const request = mockPost("@/api/auth/register", {
email,
password,
});
const response = await POST(request);
const result = await response.json();
expect(response.status).toBe(400);
expect(result.error).toBe("E-Mail und Passwort sind erforderlich.");
});
it("should return 422 if email is invalid", async () => {
const email = "invalidemail";
const password = "goodpassword123";
const { mockPost } = useTestRequest();
const request = mockPost("@/api/auth/register", {
email,
password,
});
const response = await POST(request);
const result = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe("Ungültige E-Mail-Adresse.");
});
it("should return 422 if password is too short", async () => {
const email = "validemail@email.com";
const password = "short";
const { mockPost } = useTestRequest();
const request = mockPost("@/api/auth/register", {
email,
password,
});
const response = await POST(request);
const result = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe(
"Das Passwort muss mindestens 8 Zeichen lang sein."
);
});
it("should return 422 if password is too long", async () => {
const email = "anothervalidemail@email.com";
const password =
"thispasswordiswaytoolongandshouldnotbeacceptedbecauseitexceedsthemaximumlengthallowed";
const { mockPost } = useTestRequest();
const request = mockPost("@/api/auth/register", {
email,
password,
});
const response = await POST(request);
const result = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe(
"Das Passwort muss mindestens 8 Zeichen lang sein."
);
});
it("should return 200 if account is created", async () => {
const email = "validanduniqueemail@email.com";
const password = "goodpassword123";
const { mockPost } = useTestRequest();
const request = mockPost("@/api/auth/register", {
email,
password,
});
const response = await POST(request);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.message).toBe("Account created.");
});
});
+2 -1
View File
@@ -34,10 +34,11 @@ function modelFactory(tableName, base) {
faker.seed(123);
const user = modelFactory("users", () => ({
email: faker.internet.email(),
email: faker.internet.email().toLowerCase().trim(),
password_hash: createHash("sha256")
.update(faker.internet.password())
.digest("hex"),
salt: faker.string.alphanumeric(16),
}));
export const TestFactory = {
+4
View File
@@ -0,0 +1,4 @@
export const emailRegex = new RegExp(
`^([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|"([]!#-[^-~ \t]|(\\[\t -~]))+")@([-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*|\[[\t -Z^-~]*])$`,
"i"
);
+1 -1
View File
@@ -27,7 +27,7 @@ export const useRequest = () => {
return response;
} catch (error) {
if (error.response && error.response.data && error.response.data.error) {
if (error.response && error.response.data?.error) {
throw new Error(error.response.data.error);
} else {
throw new Error("An unexpected error occurred.");