Skip to content

Commit 00c7552

Browse files
committed
feat: add email auth
1 parent 231294e commit 00c7552

File tree

7 files changed

+248
-44
lines changed

7 files changed

+248
-44
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
node_modules
1+
node_modules
2+
./data.db
3+
.env

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"dependencies": {
1515
"@auth/express": "^0.9.0",
1616
"better-sqlite3": "^11.8.1",
17+
"dotenv": "^16.4.7",
1718
"ejs": "^3.1.10",
1819
"express": "^4.21.1",
1920
"express-ejs-layouts": "^2.5.1",

server.js

+164-38
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import { ExpressAuth, getSession } from "@auth/express";
2+
import Credentials from "@auth/express/providers/credentials";
13
import Database from "better-sqlite3";
2-
import express, { urlencoded } from "express";
4+
import crypto from "crypto";
5+
import "dotenv/config";
6+
import express, { json, urlencoded } from "express"; // Import json middleware
37
import expressLayouts from "express-ejs-layouts";
48
import expressUploads from "express-fileupload";
59
import expressMethodOverride from "method-override";
@@ -10,48 +14,139 @@ const __dirname = import.meta.dirname;
1014
const app = express();
1115
const port = 3000;
1216

17+
const secret = process.env.AUTH_SECRET;
18+
19+
if (!secret) {
20+
console.error("AUTH_SECRET is not set! Authentication may not work correctly.");
21+
process.exit(1); // Exit if AUTH_SECRET is missing in production
22+
}
23+
24+
// Database setup
25+
const db = new Database("./data.db");
26+
27+
const schema = `
28+
CREATE TABLE IF NOT EXISTS "templates" (
29+
"id" TEXT PRIMARY KEY,
30+
"name" TEXT NOT NULL,
31+
"to" TEXT NOT NULL,
32+
"cc" TEXT,
33+
"bcc" TEXT,
34+
"subject" TEXT NOT NULL,
35+
"body" TEXT NOT NULL,
36+
"fields" TEXT
37+
);
38+
39+
CREATE TABLE IF NOT EXISTS "users" (
40+
"id" TEXT PRIMARY KEY,
41+
"email" TEXT NOT NULL UNIQUE,
42+
"password" TEXT NOT NULL
43+
);
44+
`;
45+
46+
db.exec(schema);
47+
1348
// Middleware
1449
app.use(expressLayouts);
1550
app.use(expressUploads());
1651
app.use(expressMethodOverride("_method"));
1752
app.use(urlencoded({ extended: true }));
53+
app.use(json()); // Add json middleware
1854
app.use(express.static("public"));
1955

20-
app.use((req, res, next) => {
21-
res.removeHeader("Content-Security-Policy");
56+
/**
57+
* @type {import("@auth/express").ExpressAuthConfig}
58+
*/
59+
const authConfig = {
60+
secret,
61+
trustHost: true,
62+
pages: {
63+
signIn: "/login", // Custom sign-in page
64+
},
65+
providers: [
66+
Credentials({
67+
credentials: {
68+
email: { label: "Email", type: "text" },
69+
password: { label: "Password", type: "password" },
70+
},
71+
async authorize(credentials, req) {
72+
if (!credentials?.email || !credentials?.password) {
73+
return null;
74+
}
75+
const user = getUser(credentials.email);
76+
77+
if (!user) {
78+
return null;
79+
}
80+
81+
const [storedHash, salt] = user.password.split(":");
82+
83+
if (!storedHash || !salt) {
84+
console.error("Invalid password format in database.");
85+
return null;
86+
}
87+
88+
const isValid = verifyPassword(credentials.password, salt, storedHash);
89+
90+
if (isValid) {
91+
// **IMPORTANT**: Return a user object that MUST contain `id` and `email`
92+
return { id: user.id, email: user.email };
93+
} else {
94+
return null;
95+
}
96+
},
97+
}),
98+
],
99+
debug: true, // Enable debug logs for troubleshooting
100+
};
101+
102+
const authSession = async (req, res, next) => {
103+
res.locals.session = await getSession(req, authConfig);
22104
next();
23-
});
105+
};
106+
107+
app.use("/auth/*", ExpressAuth(authConfig));
108+
109+
app.use(authSession);
24110

25111
// Express Settings
26112
app.set("view engine", "ejs");
27113
app.set("views", join(__dirname, "views"));
28114
app.set("layout", "layouts/layout");
115+
app.set("trust proxy", true);
29116

30-
// Database setup
31-
const db = new Database("./templates.db");
117+
// Helper functions
118+
const saltAndHashPassword = (password) => {
119+
const salt = crypto.randomBytes(16).toString("hex");
120+
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, "sha512").toString("hex");
121+
return { salt, hash };
122+
};
32123

33-
const schema = `
34-
CREATE TABLE IF NOT EXISTS "templates" (
35-
"id" TEXT PRIMARY KEY,
36-
"name" TEXT NOT NULL,
37-
"to" TEXT NOT NULL,
38-
"cc" TEXT,
39-
"bcc" TEXT,
40-
"subject" TEXT NOT NULL,
41-
"body" TEXT NOT NULL,
42-
"fields" TEXT
43-
);
44-
45-
CREATE TABLE IF NOT EXISTS users (
46-
id TEXT PRIMARY KEY,
47-
username TEXT NOT NULL,
48-
password TEXT NOT NULL
49-
);
50-
`;
124+
const verifyPassword = (password, salt, storedHash) => {
125+
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, "sha512").toString("hex");
126+
return storedHash === hash;
127+
};
51128

52-
db.exec(schema);
129+
const nanoId = (length = 5) => {
130+
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
131+
let str = "";
132+
for (let i = 0; i < length; i++) {
133+
const randomIndex = Math.floor(Math.random() * chars.length);
134+
str += chars[randomIndex];
135+
}
136+
return str;
137+
};
53138

54139
// Database helper methods
140+
const getUser = (email) => {
141+
return db.prepare('SELECT * FROM "users" WHERE "email" = ?').get(email);
142+
};
143+
144+
const addUser = (email, password) => {
145+
const { salt, hash } = saltAndHashPassword(password);
146+
const id = nanoId();
147+
db.prepare('INSERT INTO "users" ("id", "email", "password") VALUES (?, ?, ?)').run(id, email, `${hash}:${salt}`);
148+
};
149+
55150
const getTemplates = () => {
56151
const templates = db.prepare('SELECT * FROM "templates"').all();
57152
return templates.map((template) => ({
@@ -88,7 +183,50 @@ const deleteTemplate = (id) => {
88183
db.prepare('DELETE FROM "templates" WHERE "id" = ?').run(id);
89184
};
90185

186+
// Authentication middleware
187+
const authenticatedUser = async (req, res, next) => {
188+
const session = res.locals.session ?? (await getSession(req, authConfig));
189+
if (!session?.user) {
190+
res.redirect("/login");
191+
} else {
192+
next();
193+
}
194+
};
195+
91196
// Routes
197+
app.get("/login", (req, res) => {
198+
if (res.locals.session?.user) {
199+
return res.redirect("/");
200+
}
201+
res.render("login");
202+
});
203+
204+
app.get("/signup", (req, res) => {
205+
if (res.locals.session?.user) {
206+
return res.redirect("/");
207+
}
208+
res.render("signup");
209+
});
210+
211+
app.post("/signup", (req, res) => {
212+
const { email, password } = req.body;
213+
214+
try {
215+
addUser(email, password);
216+
res.redirect("/login");
217+
} catch (error) {
218+
console.log({ error });
219+
res.status(400).render("signup", { error: "Error registering user. Email may already exist." });
220+
}
221+
});
222+
223+
app.get("/logout", (req, res) => {
224+
res.redirect("/auth/signout");
225+
});
226+
227+
// Protected routes
228+
app.use(authenticatedUser);
229+
92230
app.get("/", async (_, res) => {
93231
const templates = getTemplates();
94232
res.render("home", { templates, query: "" });
@@ -167,7 +305,6 @@ app.post("/generate", async (req, res) => {
167305
const { to, cc, bcc, ...fields } = req.body;
168306

169307
const template = getTemplate(templateId);
170-
171308
if (!template) {
172309
return res.status(404).send("Template not found");
173310
}
@@ -187,7 +324,7 @@ app.post("/generate", async (req, res) => {
187324
res.redirect(mailtoLink);
188325
});
189326

190-
// Helper functions
327+
// Additional helper functions
191328
const extractDynamicFields = (content) => {
192329
content = content.trim();
193330
const regex = /\{@(.*?):(.*?)\}/gm;
@@ -200,7 +337,6 @@ const extractDynamicFields = (content) => {
200337
const [label, value = ""] = body.split("|");
201338
fields.push({ id: match, type: type.replace("@", ""), label: label.trim(), value: value.trim() });
202339
}
203-
204340
return fields;
205341
};
206342

@@ -224,16 +360,6 @@ const sanitizeJSON = (unsanitized) => {
224360
.replace(/\&/g, "\\&");
225361
};
226362

227-
const nanoId = (length = 5) => {
228-
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
229-
let str = "";
230-
for (let i = 0; i < length; i++) {
231-
const randomIndex = Math.floor(Math.random() * chars.length);
232-
str += chars[randomIndex];
233-
}
234-
return str;
235-
};
236-
237363
app.listen(port, () => {
238364
console.log(`App listening at http://localhost:${port}`);
239365
});

templates.db

-20 KB
Binary file not shown.

views/login.ejs

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<h2>Login</h2>
2+
3+
<% if (locals.message) { %>
4+
<p><%= message %></p>
5+
<% } %>
6+
7+
<form action="/auth/callback/credentials" method="POST" class="flow" style="max-width: 30rem">
8+
<input id="csrfToken" type="hidden" name="csrfToken" value="" />
9+
<input type="hidden" name="provider" value="credentials" />
10+
<div class="stack">
11+
<label for="email">Email</label>
12+
<input type="text" placeholder="[email protected]" id="email" name="email" required />
13+
</div>
14+
<div class="stack">
15+
<label for="password">Password</label>
16+
<input type="password" class="form-control" id="password" name="password" required />
17+
</div>
18+
<button type="submit" class="primary">Login</button>
19+
</form>
20+
21+
<p>Don't have an account? <a href="/signup">Sign up</a></p>
22+
23+
<script>
24+
document.addEventListener("DOMContentLoaded", async function () {
25+
try {
26+
const response = await fetch("/auth/csrf", {
27+
method: "GET",
28+
headers: {
29+
"Content-Type": "application/json",
30+
},
31+
});
32+
const data = await response.json();
33+
const csrfToken = data.csrfToken;
34+
35+
const input = document.getElementById("csrfToken");
36+
37+
console.log({ input });
38+
if (input) {
39+
console.log("CSRF token:", csrfToken);
40+
input.value = csrfToken;
41+
} else {
42+
}
43+
} catch (error) {
44+
console.error("Error fetching CSRF token:", error);
45+
}
46+
});
47+
</script>

views/partials/header.ejs

+14-5
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,21 @@
88
</a>
99
</li>
1010
</ul>
11-
<ul role="list" class="cluster">
12-
<li><a href="/" class="">Templates</a></li>
11+
<div class="cluster">
12+
<ul role="list" class="cluster">
13+
<li><a href="/" class="">Templates</a></li>
14+
<div class="divider"></div>
15+
<li><a href="/guide" class="">Guide</a></li>
16+
<li><a href="https://github.com/myMailMate/mailmate/tree/main" target="_blank" class="">GitHub</a></li>
17+
</ul>
18+
19+
<% if (session) { %>
1320
<div class="divider"></div>
14-
<li><a href="/guide" class="">Guide</a></li>
15-
<li><a href="https://github.com/myMailMate/mailmate/tree/main" target="_blank" class="">GitHub</a></li>
16-
</ul>
21+
<form action="/auth/signout" method="GET">
22+
<button class="error" type="submit">Log Out</button>
23+
</form>
24+
<% } %>
25+
</div>
1726
</nav>
1827
</header>
1928

views/signup.ejs

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<h2>Sign Up</h2>
2+
3+
<% if (locals.error) { %>
4+
<p style="color: red"><%= error %></p>
5+
<% } %>
6+
7+
<form action="/signup" method="post" class="flow" style="max-width: 30rem">
8+
<div class="stack">
9+
<label for="email">Email</label>
10+
<input type="text" placeholder="[email protected]" id="email" name="email" required />
11+
</div>
12+
<div class="stack">
13+
<label for="password">Password</label>
14+
<input type="password" class="form-control" id="password" name="password" required />
15+
</div>
16+
<button type="submit" class="primary">Sign Up</button>
17+
</form>
18+
19+
<p>Already have an account? <a href="/login">Log in</a></p>

0 commit comments

Comments
 (0)