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

Commit 7d12d24

Browse files
authored
feat: Create the user_passwords module (#127)
1 parent ba39943 commit 7d12d24

File tree

14 files changed

+526
-1552
lines changed

14 files changed

+526
-1552
lines changed

deno.lock

Lines changed: 0 additions & 1552 deletions
This file was deleted.
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "User Password Verifier",
3+
"description": "An INTERNAL-ONLY module to store and verify passwords by user ID. Used by some auth modules that require password verification.",
4+
"icon": "shield-halved",
5+
"tags": [
6+
"core",
7+
"user",
8+
"auth",
9+
"internal"
10+
],
11+
"authors": [
12+
"rivet-gg",
13+
"Blckbrry-Pi"
14+
],
15+
"status": "beta",
16+
"dependencies": {
17+
"users": {},
18+
"rate_limit": {}
19+
},
20+
"scripts": {
21+
"verify": {
22+
"name": "Verify Password for User ID",
23+
"description": "Verify that the provided password matches the provided user ID. Errors on mismatch."
24+
},
25+
"add": {
26+
"name": "Add Password for User",
27+
"description": "Register a new userID/password combination. Errors if user already has a password."
28+
},
29+
"update": {
30+
"name": "Update Password for User",
31+
"description": "Update a userID/password combination. Errors if user does not have a password."
32+
}
33+
},
34+
"errors": {
35+
"user_already_has_password": {
36+
"name": "User already has a password"
37+
},
38+
"user_does_not_have_password": {
39+
"name": "User does not yet have a password"
40+
},
41+
"password_invalid": {
42+
"name": "Password is Invalid"
43+
}
44+
}
45+
}

modules/user_passwords/scripts/add.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
// Check if the user exists before hashing the password to save compute
17+
// resources
18+
const user = await ctx.db.passwords.findFirst({
19+
where: {
20+
userId: req.userId,
21+
},
22+
});
23+
if (user) {
24+
throw new RuntimeError("user_already_has_password");
25+
}
26+
27+
// Hash the password
28+
const algo = req.algorithm || ALGORITHM_DEFAULT;
29+
const passwordHash = await hash(req.password, algo);
30+
31+
// Create an entry for the user's password
32+
await ctx.db.passwords.create({
33+
data: {
34+
userId: req.userId,
35+
passwordHash,
36+
algo,
37+
},
38+
});
39+
40+
return {};
41+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
// Ensure the user exists before hashing the password to save compute
17+
// resources
18+
const user = await ctx.db.passwords.findFirst({
19+
where: {
20+
userId: req.userId,
21+
},
22+
});
23+
if (!user) {
24+
throw new RuntimeError("user_does_not_have_password");
25+
}
26+
27+
// Hash the password
28+
const algo = req.newAlgorithm || ALGORITHM_DEFAULT;
29+
const passwordHash = await hash(req.newPassword, algo);
30+
31+
// Update the entry for the user's password
32+
await ctx.db.passwords.update({
33+
where: {
34+
userId: req.userId,
35+
},
36+
data: {
37+
passwordHash,
38+
algo,
39+
},
40+
});
41+
42+
return {};
43+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
// Look up the user password hash
16+
const user = await ctx.db.passwords.findFirst({
17+
where: {
18+
userId: req.userId,
19+
},
20+
select: {
21+
algo: true,
22+
passwordHash: true,
23+
}
24+
});
25+
if (!user) throw new RuntimeError("user_does_not_have_password");
26+
27+
// Verify the passwordHash
28+
const passwordMatches = await hashMatches(
29+
req.password,
30+
user.passwordHash,
31+
user.algo as Algorithm,
32+
);
33+
34+
if (!passwordMatches) throw new RuntimeError("password_invalid");
35+
36+
return {};
37+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
const algorithms = ["argon2", "bcrypt", "scrypt"] as const;
15+
for (const algorithm of algorithms) {
16+
// Register password
17+
const password = faker.internet.password();
18+
await ctx.modules.userPasswords.update({
19+
userId: user.id,
20+
newPassword: password,
21+
newAlgorithm: algorithm,
22+
});
23+
24+
// Verify password
25+
await ctx.modules.userPasswords.verify({
26+
userId: user.id,
27+
password: password,
28+
});
29+
30+
// Change password
31+
const newPass = faker.internet.password();
32+
await ctx.modules.userPasswords.update({
33+
userId: user.id,
34+
newPassword: newPass,
35+
newAlgorithm: algorithm,
36+
});
37+
38+
// Verify new password
39+
await ctx.modules.userPasswords.verify({
40+
userId: user.id,
41+
password: newPass,
42+
});
43+
}
44+
});
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+
});

0 commit comments

Comments
 (0)