From 31310d61e1438cfc11f910039cb5a7ef93b20f1c Mon Sep 17 00:00:00 2001 From: Daniel Flanagan Date: Sat, 8 Oct 2022 02:01:48 -0500 Subject: [PATCH] Auth working --- db/migrations.ts | 36 +++++++++++----------- db/mod.ts | 69 +++++++++++++++++++++++++++++++++++++++++-- fresh.gen.ts | 40 ++++++++++++++----------- routes/_middleware.ts | 25 ++++++++++++++-- routes/dashboard.tsx | 38 ++++++++++++++++++++++++ routes/login.tsx | 24 ++++++++++----- routes/logout.tsx | 23 +++++++++++++++ types.ts | 22 +++++++++----- 8 files changed, 222 insertions(+), 55 deletions(-) create mode 100644 routes/dashboard.tsx create mode 100644 routes/logout.tsx 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;