Skip to content
This repository was archived by the owner on Sep 17, 2024. It is now read-only.

Commit a84215f

Browse files
committed
feat: Create the user_passwords module
1 parent ce34e98 commit a84215f

File tree

15 files changed

+535
-0
lines changed

15 files changed

+535
-0
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- CreateTable
2+
CREATE TABLE "Passwords" (
3+
"userId" UUID NOT NULL,
4+
"passwordHash" TEXT NOT NULL,
5+
"algo" TEXT NOT NULL,
6+
7+
CONSTRAINT "Passwords_pkey" PRIMARY KEY ("userId")
8+
);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Please do not edit this file manually
2+
# It should be added in your version-control system (i.e. Git)
3+
provider = "postgresql"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Do not modify this `datasource` block
2+
datasource db {
3+
provider = "postgresql"
4+
url = env("DATABASE_URL")
5+
}
6+
7+
model Passwords {
8+
userId String @db.Uuid @id
9+
passwordHash String
10+
algo String
11+
}

modules/user_passwords/module.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "User Password Verifier",
3+
"dependencies": {
4+
"users": {},
5+
"rate_limit": {}
6+
},
7+
"scripts": {
8+
"verify": {
9+
"name": "Verify Password for User ID",
10+
"description": "Verify that the provided password matches the provided user ID. Errors on mismatch."
11+
},
12+
"add": {
13+
"name": "Add Password for User",
14+
"description": "Register a new userID/password combination. Errors if user already has a password."
15+
},
16+
"update": {
17+
"name": "Update Password for User",
18+
"description": "Update a userID/password combination. Errors if user does not have a password."
19+
}
20+
},
21+
"errors": {
22+
"user_already_has_password": {
23+
"name": "User already has a password"
24+
},
25+
"user_does_not_have_password": {
26+
"name": "User does not yet have a password"
27+
},
28+
"password_invalid": {
29+
"name": "Password is Invalid"
30+
}
31+
}
32+
}

modules/user_passwords/scripts/add.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts";
2+
import { ALGORITHM_DEFAULT, Algorithm, hash } from "../utils/common.ts";
3+
4+
export interface Request {
5+
userId: string;
6+
password: string;
7+
algorithm?: Algorithm;
8+
}
9+
10+
export type Response = Empty;
11+
12+
export async function run(
13+
ctx: ScriptContext,
14+
req: Request,
15+
): Promise<Response> {
16+
await ctx.modules.rateLimit.throttle({
17+
key: req.userId,
18+
period: 10,
19+
requests: 10,
20+
type: "user",
21+
});
22+
23+
// Check if the user exists before hashing the password to save compute
24+
// resources
25+
const user = await ctx.db.passwords.findFirst({
26+
where: {
27+
userId: req.userId,
28+
},
29+
});
30+
if (user) {
31+
throw new RuntimeError("user_already_has_password");
32+
}
33+
34+
// Hash the password
35+
const algo = req.algorithm || ALGORITHM_DEFAULT;
36+
const passwordHash = await hash(req.password, algo);
37+
38+
// Create an entry for the user's password
39+
await ctx.db.passwords.create({
40+
data: {
41+
userId: req.userId,
42+
passwordHash,
43+
algo,
44+
},
45+
});
46+
47+
return {};
48+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts";
2+
import { ALGORITHM_DEFAULT, Algorithm, hash } from "../utils/common.ts";
3+
4+
export interface Request {
5+
userId: string;
6+
newPassword: string;
7+
newAlgorithm?: Algorithm;
8+
}
9+
10+
export type Response = Empty;
11+
12+
export async function run(
13+
ctx: ScriptContext,
14+
req: Request,
15+
): Promise<Response> {
16+
await ctx.modules.rateLimit.throttle({
17+
key: req.userId,
18+
period: 10,
19+
requests: 10,
20+
type: "user",
21+
});
22+
23+
// Ensure the user exists before hashing the password to save compute
24+
// resources
25+
const user = await ctx.db.passwords.findFirst({
26+
where: {
27+
userId: req.userId,
28+
},
29+
});
30+
if (!user) {
31+
throw new RuntimeError("user_does_not_have_password");
32+
}
33+
34+
// Hash the password
35+
const algo = req.newAlgorithm || ALGORITHM_DEFAULT;
36+
const passwordHash = await hash(req.newPassword, algo);
37+
38+
// Update the entry for the user's password
39+
await ctx.db.passwords.update({
40+
where: {
41+
userId: req.userId,
42+
},
43+
data: {
44+
passwordHash,
45+
algo,
46+
},
47+
});
48+
49+
return {};
50+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts";
2+
import { Algorithm, hashMatches } from "../utils/common.ts";
3+
4+
export interface Request {
5+
userId: string;
6+
password: string;
7+
}
8+
9+
export type Response = Empty;
10+
11+
export async function run(
12+
ctx: ScriptContext,
13+
req: Request,
14+
): Promise<Response> {
15+
await ctx.modules.rateLimit.throttle({
16+
key: req.userId,
17+
period: 10,
18+
requests: 10,
19+
type: "user",
20+
});
21+
22+
// Look up the user password hash
23+
const user = await ctx.db.passwords.findFirst({
24+
where: {
25+
userId: req.userId,
26+
},
27+
select: {
28+
algo: true,
29+
passwordHash: true,
30+
}
31+
});
32+
if (!user) throw new RuntimeError("user_does_not_have_password");
33+
34+
// Verify the passwordHash
35+
const passwordMatches = await hashMatches(
36+
req.password,
37+
user.passwordHash,
38+
user.algo as Algorithm,
39+
);
40+
41+
if (!passwordMatches) throw new RuntimeError("password_invalid");
42+
43+
return {};
44+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test, TestContext } from "../module.gen.ts";
2+
import { assertExists } from "https://deno.land/std@0.217.0/assert/mod.ts";
3+
import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts";
4+
5+
test("algorithms", async (ctx: TestContext) => {
6+
const { user } = await ctx.modules.users.create({
7+
username: faker.internet.userName(),
8+
});
9+
assertExists(user);
10+
11+
// Set up user
12+
await ctx.modules.userPasswords.add({ userId: user.id, password: "password" });
13+
14+
// NOTE: Argon2 is temporarily disabled due to its reliance on `Deno.dlopen`
15+
// const algorithms = ["argon2", "bcrypt", "scrypt"] as const;
16+
const algorithms = ["bcrypt", "scrypt"] as const;
17+
for (const algorithm of algorithms) {
18+
// Register password
19+
const password = faker.internet.password();
20+
await ctx.modules.userPasswords.update({
21+
userId: user.id,
22+
newPassword: password,
23+
newAlgorithm: algorithm,
24+
});
25+
26+
// Verify password
27+
await ctx.modules.userPasswords.verify({
28+
userId: user.id,
29+
password: password,
30+
});
31+
32+
// Change password
33+
const newPass = faker.internet.password();
34+
await ctx.modules.userPasswords.update({
35+
userId: user.id,
36+
newPassword: newPass,
37+
newAlgorithm: algorithm,
38+
});
39+
40+
// Verify new password
41+
await ctx.modules.userPasswords.verify({
42+
userId: user.id,
43+
password: newPass,
44+
});
45+
}
46+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { test, TestContext } from "../module.gen.ts";
2+
import { assertExists, assertEquals, assertRejects } from "https://deno.land/std@0.217.0/assert/mod.ts";
3+
import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts";
4+
import { RuntimeError } from "../module.gen.ts";
5+
6+
test("accept_matching_password", async (ctx: TestContext) => {
7+
const { user } = await ctx.modules.users.create({
8+
username: faker.internet.userName(),
9+
});
10+
assertExists(user);
11+
12+
// Register password
13+
const password = faker.internet.password();
14+
await ctx.modules.userPasswords.add({
15+
userId: user.id,
16+
password,
17+
});
18+
19+
// Verify password
20+
await ctx.modules.userPasswords.verify({
21+
userId: user.id,
22+
password: password,
23+
});
24+
25+
// Change password
26+
const newPass = faker.internet.password();
27+
await ctx.modules.userPasswords.update({
28+
userId: user.id,
29+
newPassword: newPass,
30+
});
31+
32+
// Verify new password
33+
await ctx.modules.userPasswords.verify({
34+
userId: user.id,
35+
password: newPass,
36+
});
37+
});
38+
39+
40+
test("reject_different_password", async (ctx: TestContext) => {
41+
const { user } = await ctx.modules.users.create({
42+
username: faker.internet.userName(),
43+
});
44+
assertExists(user);
45+
46+
// Register password
47+
const password = faker.internet.password();
48+
await ctx.modules.userPasswords.add({
49+
userId: user.id,
50+
password,
51+
});
52+
53+
const wrongPassword = faker.internet.password();
54+
55+
// Verify incorrect password
56+
const error = await assertRejects(async () => {
57+
await ctx.modules.userPasswords.verify({
58+
userId: user.id,
59+
password: wrongPassword,
60+
});
61+
}, RuntimeError);
62+
63+
// Verify error message
64+
assertExists(error.message);
65+
assertEquals(error.code, "password_invalid");
66+
});
67+
68+
test("reject_unregistered", async (ctx: TestContext) => {
69+
const { user } = await ctx.modules.users.create({
70+
username: faker.internet.userName(),
71+
});
72+
assertExists(user);
73+
74+
// Register password
75+
const password = faker.internet.password();
76+
await ctx.modules.userPasswords.add({
77+
userId: user.id,
78+
password,
79+
});
80+
81+
const wrongPassword = faker.internet.password();
82+
83+
// Verify "correct" password with unregistered user
84+
const error = await assertRejects(async () => {
85+
await ctx.modules.userPasswords.verify({
86+
userId: crypto.randomUUID(),
87+
password: wrongPassword,
88+
});
89+
}, RuntimeError);
90+
91+
// Verify error message
92+
assertExists(error.message);
93+
assertEquals(error.code, "user_does_not_have_password");
94+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
hash as hashArgon2,
3+
HashOptions,
4+
ThreadMode,
5+
Variant,
6+
verify as verifyArgon2,
7+
Version,
8+
} from "https://deno.land/x/argon2_ffi@v1.0.5/mod.ts";
9+
import { generateSalt } from "./common.ts";
10+
11+
// OWASP recommended defaults
12+
const argon2Defaults: Partial<HashOptions> = {
13+
timeCost: 3, // 3 iterations
14+
memoryCost: 12 * 1024, // 12MiB of memory
15+
lanes: 1,
16+
threadMode: ThreadMode.Parallel,
17+
18+
variant: Variant.Argon2id,
19+
version: Version.V13,
20+
};
21+
22+
export function createHash(password: string) {
23+
return hashArgon2(password, {
24+
...argon2Defaults,
25+
salt: generateSalt(),
26+
});
27+
}
28+
29+
export function hashMatches(guess: string, hash: string) {
30+
return verifyArgon2(guess, hash);
31+
}

0 commit comments

Comments
 (0)