Auth working
This commit is contained in:
parent
967919f9e7
commit
31310d61e1
|
@ -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",
|
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
69
db/mod.ts
69
db/mod.ts
|
@ -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> {
|
||||||
|
|
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 $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,
|
||||||
|
|
|
@ -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
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 { 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
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;
|
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;
|
||||||
|
|
Loading…
Reference in a new issue