diff --git a/db/migrations.ts b/db/migrations.ts
index 73dee48..6a0a4e8 100644
--- a/db/migrations.ts
+++ b/db/migrations.ts
@@ -31,19 +31,11 @@ const functions = [
];
const tables: Record = {
- "note": {
- columns: [id, "content text not null", ...timestamps],
- },
"user": {
- prepStatements: [
- "drop type if exists user_status",
- "create type user_status as enum ('unverified', 'verified', 'superadmin')",
- ],
columns: [
id,
"username text not null unique",
"password_digest text not null",
- "status user_status not null",
"display_name text",
...timestamps,
],
@@ -52,19 +44,17 @@ const tables: Record = {
],
},
"user_token": {
- prepStatements: [
- "drop type if exists user_token_type",
- "create type user_token_type as enum ('session', 'reset')",
- ],
columns: [
- "token_digest bytea unique",
- "type user_token_type not null",
- "sent_to text not null",
+ "digest bytea not null unique",
+ "user_id uuid not null",
+ "data jsonb",
createdAtTimestamp,
],
additionalStatements: [
- "create index team_user_type on user_token (type)",
- "create index team_user_sent_to on user_token (sent_to)",
+ "create index team_data_type on user_token using hash ((data->'type'))",
+ ],
+ additionalTableStatements: [
+ 'constraint fk_user foreign key(user_id) references "user"(id) on delete cascade',
],
},
"team": {
@@ -99,6 +89,17 @@ const tables: Record = {
"create index status_idx on team_user (status)",
],
},
+ "note": {
+ columns: [
+ id,
+ "user_id uuid default null",
+ "content text not null",
+ ...timestamps,
+ ],
+ additionalTableStatements: [
+ 'constraint fk_user foreign key(user_id) references "user"(id) on delete cascade',
+ ],
+ },
};
const createExtensions = extensions.map((s) =>
@@ -159,7 +160,6 @@ try {
username: "lytedev",
passwordDigest:
"$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW",
- status: "superadmin",
}),
]),
);
diff --git a/db/mod.ts b/db/mod.ts
index 32d1438..c4f4333 100644
--- a/db/mod.ts
+++ b/db/mod.ts
@@ -9,15 +9,20 @@ import {
type QueryObjectResult,
} from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments";
import { config } from "@/config.ts";
+import * as base64 from "$std/encoding/base64.ts";
import {
type Identifiable,
type Note,
type Team,
type Timestamped,
+ type Token,
+ type TokenDigest,
type User,
} from "@/types.ts";
+import { sha256 } from "https://denopkg.com/chiefbiiko/sha256@v1.0.0/mod.ts";
+
export { PostgresError };
export { type QueryObjectResult };
@@ -110,8 +115,8 @@ export async function createUser(
await queryObject<{ teamId: string; userId: string }>(
`
with new_user as (
- insert into "user" (username, password_digest, status)
- values ($username, $passwordDigest, 'unverified')
+ insert into "user" (username, password_digest)
+ values ($username, $passwordDigest)
returning id as user_id
), new_team as (
insert into "team" (display_name)
@@ -136,6 +141,48 @@ export async function createUser(
]);
}
+const TOKEN_SIZE = 32;
+
+export async function createToken(
+ token: Omit, "digest">,
+): Promise {
+ const intermediateToken: Partial = { ...token };
+ if (!intermediateToken.bytes) {
+ intermediateToken.bytes = new Uint8Array(TOKEN_SIZE);
+ crypto.getRandomValues(intermediateToken.bytes);
+ }
+ if (!intermediateToken.digest) {
+ const digest = sha256(intermediateToken.bytes);
+ if (!(digest instanceof Uint8Array)) throw "token digest was non-brinary";
+ intermediateToken.digest = digest;
+ }
+ if (!intermediateToken.data) intermediateToken.data = null;
+ const result = singleRow(
+ await queryObject(
+ `
+ insert into "user_token" (digest, user_id, data)
+ values ($digest, $userId, $data)
+ returning *
+ `,
+ intermediateToken,
+ ),
+ );
+ if (result) return { ...intermediateToken, ...result };
+ return null;
+}
+
+export async function getToken(token: TokenDigest): Promise {
+ const digest = base64.decode(token);
+ return singleRow(
+ await queryObject(
+ `
+ select * from user_token where digest = $1
+ `,
+ [digest],
+ ),
+ );
+}
+
export async function getUser(
{ id, username }: Partial,
): Promise {
@@ -149,6 +196,24 @@ export async function getUser(
);
}
+export async function getUserFromNonExpiredLoginToken(
+ token: TokenDigest,
+): Promise {
+ const digest = sha256(base64.decode(token));
+ return singleRow(
+ await queryObject(
+ `
+ select u.* from "user_token" ut
+ left join "user" u on u.id = ut.user_id
+ where ut."digest" = $1
+ and ut."data"->>'type' = 'login'
+ and now() < (ut.created_at + '7 days'::interval)
+ `,
+ [digest],
+ ),
+ );
+}
+
export async function getTeam(
{ id }: Partial,
): Promise {
diff --git a/fresh.gen.ts b/fresh.gen.ts
index 4d6bd63..069359d 100644
--- a/fresh.gen.ts
+++ b/fresh.gen.ts
@@ -11,15 +11,17 @@ import * as $4 from "./routes/about.tsx";
import * as $5 from "./routes/api/joke.ts";
import * as $6 from "./routes/api/random-uuid.ts";
import * as $7 from "./routes/countdown.tsx";
-import * as $8 from "./routes/github/[username].tsx";
-import * as $9 from "./routes/index.tsx";
-import * as $10 from "./routes/login.tsx";
-import * as $11 from "./routes/note.tsx";
-import * as $12 from "./routes/note/[id].tsx";
-import * as $13 from "./routes/note/create.tsx";
-import * as $14 from "./routes/register.tsx";
-import * as $15 from "./routes/route-config-example.tsx";
-import * as $16 from "./routes/search.tsx";
+import * as $8 from "./routes/dashboard.tsx";
+import * as $9 from "./routes/github/[username].tsx";
+import * as $10 from "./routes/index.tsx";
+import * as $11 from "./routes/login.tsx";
+import * as $12 from "./routes/logout.tsx";
+import * as $13 from "./routes/note.tsx";
+import * as $14 from "./routes/note/[id].tsx";
+import * as $15 from "./routes/note/create.tsx";
+import * as $16 from "./routes/register.tsx";
+import * as $17 from "./routes/route-config-example.tsx";
+import * as $18 from "./routes/search.tsx";
import * as $$0 from "./islands/Countdown.tsx";
import * as $$1 from "./islands/Counter.tsx";
@@ -33,15 +35,17 @@ const manifest = {
"./routes/api/joke.ts": $5,
"./routes/api/random-uuid.ts": $6,
"./routes/countdown.tsx": $7,
- "./routes/github/[username].tsx": $8,
- "./routes/index.tsx": $9,
- "./routes/login.tsx": $10,
- "./routes/note.tsx": $11,
- "./routes/note/[id].tsx": $12,
- "./routes/note/create.tsx": $13,
- "./routes/register.tsx": $14,
- "./routes/route-config-example.tsx": $15,
- "./routes/search.tsx": $16,
+ "./routes/dashboard.tsx": $8,
+ "./routes/github/[username].tsx": $9,
+ "./routes/index.tsx": $10,
+ "./routes/login.tsx": $11,
+ "./routes/logout.tsx": $12,
+ "./routes/note.tsx": $13,
+ "./routes/note/[id].tsx": $14,
+ "./routes/note/create.tsx": $15,
+ "./routes/register.tsx": $16,
+ "./routes/route-config-example.tsx": $17,
+ "./routes/search.tsx": $18,
},
islands: {
"./islands/Countdown.tsx": $$0,
diff --git a/routes/_middleware.ts b/routes/_middleware.ts
index 70f2013..0553de9 100644
--- a/routes/_middleware.ts
+++ b/routes/_middleware.ts
@@ -1,15 +1,34 @@
import { MiddlewareHandlerContext } from "$fresh/server.ts";
+import { deleteCookie, getCookies } from "$std/http/cookie.ts";
+import { getUserFromNonExpiredLoginToken } from "@/db/mod.ts";
interface State {
data: string;
}
export async function handler(
- _: Request,
+ request: Request,
ctx: MiddlewareHandlerContext,
) {
- ctx.state.data = "myData";
+ ctx.state.data = "";
+ let hasBadAuthCookie = false;
+ const { lsauth } = getCookies(request.headers);
+ console.log("lsauth cookie:", lsauth);
+ if (lsauth) {
+ const user = await getUserFromNonExpiredLoginToken(lsauth);
+ if (!user) hasBadAuthCookie = true;
+ else {ctx.state.data += "user:" + JSON.stringify({
+ id: user.id,
+ username: user.username,
+ displayName: user.displayName || user.username,
+ }) +
+ "\n";}
+ }
+
const resp = await ctx.next();
- if (resp) resp.headers.set("server", "fresh server");
+ if (resp) {
+ resp.headers.set("server", "fresh server");
+ if (hasBadAuthCookie) deleteCookie(resp.headers, "lsauth");
+ }
return resp;
}
diff --git a/routes/dashboard.tsx b/routes/dashboard.tsx
new file mode 100644
index 0000000..ad64411
--- /dev/null
+++ b/routes/dashboard.tsx
@@ -0,0 +1,38 @@
+import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
+import { Page } from "@/components/Page.tsx";
+// import { getToken, getUser } from "@/db/mod.ts";
+// import * as base64 from "$std/encoding/base64.ts";
+import { getCookies } from "$std/http/cookie.ts";
+import { type User } from "@/types.ts";
+
+export const handler: Handlers = {
+ async GET(request: Request, context) {
+ return await context.render(context.state.data);
+ },
+};
+
+export default function Dashboard({ data }: PageProps) {
+ if (data) {
+ return You(data);
+ } else {
+ return LoginRequired();
+ }
+}
+
+function You(data: unknown) {
+ return (
+
+
+ You are
{data}
.
+
+
+ );
+}
+
+function LoginRequired() {
+ return (
+
+ You need to login first!
+
+ );
+}
diff --git a/routes/login.tsx b/routes/login.tsx
index 565c4ba..20fbfd5 100644
--- a/routes/login.tsx
+++ b/routes/login.tsx
@@ -1,7 +1,9 @@
import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
import { Page } from "@/components/Page.tsx";
-import { getUser } from "@/db/mod.ts";
+import { createToken, getUser } from "@/db/mod.ts";
+import * as base64 from "$std/encoding/base64.ts";
+import { setCookie } from "$std/http/cookie.ts";
type UserID = string;
@@ -25,16 +27,24 @@ export const handler: Handlers = {
return await context.render({ message: "no password provided" });
}
- const result = await getUser({ username: username.toString() });
- if (result == null || result.rows.length < 1) {
+ const user = await getUser({ username: username.toString() });
+ if (!user) {
return await invalidLogin(context);
}
- const { rows: [{ id, passwordDigest }] } = result;
- if (await compare(password.toString(), passwordDigest)) {
- return await context.render(id);
- } else {
+ if (!await compare(password.toString(), user.passwordDigest)) {
return await invalidLogin(context);
}
+
+ const token = await createToken({
+ userId: user.id,
+ data: { type: "login" },
+ });
+ if (!token || !token.bytes) throw "failed to create token";
+ const cookie = base64.encode(token.bytes);
+
+ const response = await context.render(user.id);
+ setCookie(response.headers, { name: "lsauth", value: cookie });
+ return response;
},
};
diff --git a/routes/logout.tsx b/routes/logout.tsx
new file mode 100644
index 0000000..0548e14
--- /dev/null
+++ b/routes/logout.tsx
@@ -0,0 +1,23 @@
+import { Handlers } from "$fresh/server.ts";
+import { Page } from "@/components/Page.tsx";
+// import { getToken, getUser } from "@/db/mod.ts";
+// import * as base64 from "$std/encoding/base64.ts";
+import { deleteCookie } from "$std/http/cookie.ts";
+
+export const handler: Handlers = {
+ async GET(request: Request, context) {
+ const response = await context.render();
+ deleteCookie(response.headers, "lsauth");
+ return response;
+ },
+};
+
+export default function LoggedOut() {
+ return (
+
+
+ If you were logged in before, we've logged you out.
+
+
+ );
+}
diff --git a/types.ts b/types.ts
index 121f1c4..ce19b8f 100644
--- a/types.ts
+++ b/types.ts
@@ -2,15 +2,15 @@ export interface Identifiable {
id: string;
}
-export interface Creatable {
+export interface Created {
createdAt: Date;
}
-export interface Updatable {
+export interface Updated {
updatedAt: Date;
}
-export type Timestamped = Creatable & Updatable;
+export type Timestamped = Created & Updated;
export interface Note extends Identifiable, Timestamped {
content: string;
@@ -20,12 +20,20 @@ export interface Team extends Identifiable, Timestamped {
displayName: string;
}
-export type UserStatus = "unverified" | "verified" | "superadmin";
-
export interface User extends Identifiable, Timestamped {
username: string;
- email?: string;
passwordDigest: string;
displayName?: string;
- status: UserStatus;
}
+
+type IdentifierFor = T["id"];
+
+export interface Token extends Created {
+ bytes?: Uint8Array;
+ digest: Uint8Array;
+ userId: IdentifierFor;
+ data: Record | null;
+}
+
+/** 32 bytes base64-encoded */
+export type TokenDigest = string;