Auth working

This commit is contained in:
Daniel Flanagan 2022-10-08 02:01:48 -05:00
parent 967919f9e7
commit 31310d61e1
Signed by: lytedev
GPG key ID: 5B2020A0F9921EF4
8 changed files with 222 additions and 55 deletions

View file

@ -31,19 +31,11 @@ const functions = [
]; ];
const tables: Record<string, TableSpec> = { const tables: Record<string, TableSpec> = {
"note": {
columns: [id, "content text not null", ...timestamps],
},
"user": { "user": {
prepStatements: [
"drop type if exists user_status",
"create type user_status as enum ('unverified', 'verified', 'superadmin')",
],
columns: [ columns: [
id, id,
"username text not null unique", "username text not null unique",
"password_digest text not null", "password_digest text not null",
"status user_status not null",
"display_name text", "display_name text",
...timestamps, ...timestamps,
], ],
@ -52,19 +44,17 @@ const tables: Record<string, TableSpec> = {
], ],
}, },
"user_token": { "user_token": {
prepStatements: [
"drop type if exists user_token_type",
"create type user_token_type as enum ('session', 'reset')",
],
columns: [ columns: [
"token_digest bytea unique", "digest bytea not null unique",
"type user_token_type not null", "user_id uuid not null",
"sent_to text not null", "data jsonb",
createdAtTimestamp, createdAtTimestamp,
], ],
additionalStatements: [ additionalStatements: [
"create index team_user_type on user_token (type)", "create index team_data_type on user_token using hash ((data->'type'))",
"create index team_user_sent_to on user_token (sent_to)", ],
additionalTableStatements: [
'constraint fk_user foreign key(user_id) references "user"(id) on delete cascade',
], ],
}, },
"team": { "team": {
@ -99,6 +89,17 @@ const tables: Record<string, TableSpec> = {
"create index status_idx on team_user (status)", "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) => const createExtensions = extensions.map((s) =>
@ -159,7 +160,6 @@ try {
username: "lytedev", username: "lytedev",
passwordDigest: passwordDigest:
"$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW", "$2a$10$9fyDAOz6H4a393KHyjbvIe1WFxbhCJhq/CZmlXcEg4d1bE9Ey25WW",
status: "superadmin",
}), }),
]), ]),
); );

View file

@ -9,15 +9,20 @@ import {
type QueryObjectResult, type QueryObjectResult,
} from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments"; } from "https://deno.land/x/postgres@v0.16.1/query/query.ts?s=QueryArguments";
import { config } from "@/config.ts"; import { config } from "@/config.ts";
import * as base64 from "$std/encoding/base64.ts";
import { import {
type Identifiable, type Identifiable,
type Note, type Note,
type Team, type Team,
type Timestamped, type Timestamped,
type Token,
type TokenDigest,
type User, type User,
} from "@/types.ts"; } from "@/types.ts";
import { sha256 } from "https://denopkg.com/chiefbiiko/sha256@v1.0.0/mod.ts";
export { PostgresError }; export { PostgresError };
export { type QueryObjectResult }; export { type QueryObjectResult };
@ -110,8 +115,8 @@ export async function createUser(
await queryObject<{ teamId: string; userId: string }>( await queryObject<{ teamId: string; userId: string }>(
` `
with new_user as ( with new_user as (
insert into "user" (username, password_digest, status) insert into "user" (username, password_digest)
values ($username, $passwordDigest, 'unverified') values ($username, $passwordDigest)
returning id as user_id returning id as user_id
), new_team as ( ), new_team as (
insert into "team" (display_name) insert into "team" (display_name)
@ -136,6 +141,48 @@ export async function createUser(
]); ]);
} }
const TOKEN_SIZE = 32;
export async function createToken(
token: Omit<Ungenerated<Token>, "digest">,
): Promise<Token | null> {
const intermediateToken: Partial<Token> = { ...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<Token>(
`
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<Token | null> {
const digest = base64.decode(token);
return singleRow(
await queryObject(
`
select * from user_token where digest = $1
`,
[digest],
),
);
}
export async function getUser( export async function getUser(
{ id, username }: Partial<User>, { id, username }: Partial<User>,
): Promise<User | null> { ): Promise<User | null> {
@ -149,6 +196,24 @@ export async function getUser(
); );
} }
export async function getUserFromNonExpiredLoginToken(
token: TokenDigest,
): Promise<User | null> {
const digest = sha256(base64.decode(token));
return singleRow(
await queryObject<User>(
`
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( export async function getTeam(
{ id }: Partial<Team>, { id }: Partial<Team>,
): Promise<Team | null> { ): Promise<Team | null> {

View file

@ -11,15 +11,17 @@ import * as $4 from "./routes/about.tsx";
import * as $5 from "./routes/api/joke.ts"; import * as $5 from "./routes/api/joke.ts";
import * as $6 from "./routes/api/random-uuid.ts"; import * as $6 from "./routes/api/random-uuid.ts";
import * as $7 from "./routes/countdown.tsx"; import * as $7 from "./routes/countdown.tsx";
import * as $8 from "./routes/github/[username].tsx"; import * as $8 from "./routes/dashboard.tsx";
import * as $9 from "./routes/index.tsx"; import * as $9 from "./routes/github/[username].tsx";
import * as $10 from "./routes/login.tsx"; import * as $10 from "./routes/index.tsx";
import * as $11 from "./routes/note.tsx"; import * as $11 from "./routes/login.tsx";
import * as $12 from "./routes/note/[id].tsx"; import * as $12 from "./routes/logout.tsx";
import * as $13 from "./routes/note/create.tsx"; import * as $13 from "./routes/note.tsx";
import * as $14 from "./routes/register.tsx"; import * as $14 from "./routes/note/[id].tsx";
import * as $15 from "./routes/route-config-example.tsx"; import * as $15 from "./routes/note/create.tsx";
import * as $16 from "./routes/search.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 $$0 from "./islands/Countdown.tsx";
import * as $$1 from "./islands/Counter.tsx"; import * as $$1 from "./islands/Counter.tsx";
@ -33,15 +35,17 @@ const manifest = {
"./routes/api/joke.ts": $5, "./routes/api/joke.ts": $5,
"./routes/api/random-uuid.ts": $6, "./routes/api/random-uuid.ts": $6,
"./routes/countdown.tsx": $7, "./routes/countdown.tsx": $7,
"./routes/github/[username].tsx": $8, "./routes/dashboard.tsx": $8,
"./routes/index.tsx": $9, "./routes/github/[username].tsx": $9,
"./routes/login.tsx": $10, "./routes/index.tsx": $10,
"./routes/note.tsx": $11, "./routes/login.tsx": $11,
"./routes/note/[id].tsx": $12, "./routes/logout.tsx": $12,
"./routes/note/create.tsx": $13, "./routes/note.tsx": $13,
"./routes/register.tsx": $14, "./routes/note/[id].tsx": $14,
"./routes/route-config-example.tsx": $15, "./routes/note/create.tsx": $15,
"./routes/search.tsx": $16, "./routes/register.tsx": $16,
"./routes/route-config-example.tsx": $17,
"./routes/search.tsx": $18,
}, },
islands: { islands: {
"./islands/Countdown.tsx": $$0, "./islands/Countdown.tsx": $$0,

View file

@ -1,15 +1,34 @@
import { MiddlewareHandlerContext } from "$fresh/server.ts"; import { MiddlewareHandlerContext } from "$fresh/server.ts";
import { deleteCookie, getCookies } from "$std/http/cookie.ts";
import { getUserFromNonExpiredLoginToken } from "@/db/mod.ts";
interface State { interface State {
data: string; data: string;
} }
export async function handler( export async function handler(
_: Request, request: Request,
ctx: MiddlewareHandlerContext<State>, ctx: MiddlewareHandlerContext<State>,
) { ) {
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(); 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; return resp;
} }

38
routes/dashboard.tsx Normal file
View file

@ -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<unknown> = {
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 (
<Page>
<p>
You are <pre>{data}</pre>.
</p>
</Page>
);
}
function LoginRequired() {
return (
<Page>
<a href="/login">You need to login first!</a>
</Page>
);
}

View file

@ -1,7 +1,9 @@
import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts"; import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; import { compare } from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
import { Page } from "@/components/Page.tsx"; 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; type UserID = string;
@ -25,16 +27,24 @@ export const handler: Handlers<UserID | LoginError | null> = {
return await context.render({ message: "no password provided" }); return await context.render({ message: "no password provided" });
} }
const result = await getUser({ username: username.toString() }); const user = await getUser({ username: username.toString() });
if (result == null || result.rows.length < 1) { if (!user) {
return await invalidLogin(context); return await invalidLogin(context);
} }
const { rows: [{ id, passwordDigest }] } = result; if (!await compare(password.toString(), user.passwordDigest)) {
if (await compare(password.toString(), passwordDigest)) {
return await context.render(id);
} else {
return await invalidLogin(context); 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;
}, },
}; };

23
routes/logout.tsx Normal file
View file

@ -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<unknown> = {
async GET(request: Request, context) {
const response = await context.render();
deleteCookie(response.headers, "lsauth");
return response;
},
};
export default function LoggedOut() {
return (
<Page>
<p>
If you were logged in before, we've logged you out.
</p>
</Page>
);
}

View file

@ -2,15 +2,15 @@ export interface Identifiable {
id: string; id: string;
} }
export interface Creatable { export interface Created {
createdAt: Date; createdAt: Date;
} }
export interface Updatable { export interface Updated {
updatedAt: Date; updatedAt: Date;
} }
export type Timestamped = Creatable & Updatable; export type Timestamped = Created & Updated;
export interface Note extends Identifiable, Timestamped { export interface Note extends Identifiable, Timestamped {
content: string; content: string;
@ -20,12 +20,20 @@ export interface Team extends Identifiable, Timestamped {
displayName: string; displayName: string;
} }
export type UserStatus = "unverified" | "verified" | "superadmin";
export interface User extends Identifiable, Timestamped { export interface User extends Identifiable, Timestamped {
username: string; username: string;
email?: string;
passwordDigest: string; passwordDigest: string;
displayName?: string; displayName?: string;
status: UserStatus;
} }
type IdentifierFor<T extends Identifiable> = T["id"];
export interface Token extends Created {
bytes?: Uint8Array;
digest: Uint8Array;
userId: IdentifierFor<User>;
data: Record<string, unknown> | null;
}
/** 32 bytes base64-encoded */
export type TokenDigest = string;