Auth working
This commit is contained in:
parent
967919f9e7
commit
31310d61e1
|
@ -31,19 +31,11 @@ const functions = [
|
|||
];
|
||||
|
||||
const tables: Record<string, TableSpec> = {
|
||||
"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<string, TableSpec> = {
|
|||
],
|
||||
},
|
||||
"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<string, TableSpec> = {
|
|||
"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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
|
69
db/mod.ts
69
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<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(
|
||||
{ id, username }: Partial<User>,
|
||||
): 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(
|
||||
{ id }: Partial<Team>,
|
||||
): Promise<Team | null> {
|
||||
|
|
40
fresh.gen.ts
40
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,
|
||||
|
|
|
@ -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<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();
|
||||
if (resp) resp.headers.set("server", "fresh server");
|
||||
if (resp) {
|
||||
resp.headers.set("server", "fresh server");
|
||||
if (hasBadAuthCookie) deleteCookie(resp.headers, "lsauth");
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
|
38
routes/dashboard.tsx
Normal file
38
routes/dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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<UserID | LoginError | null> = {
|
|||
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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
23
routes/logout.tsx
Normal file
23
routes/logout.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
types.ts
22
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 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;
|
||||
|
|
Loading…
Reference in a new issue