Add basic login screen with form-based authentication
Adds /login route with HTML template that handles GET (show form) and POST (authenticate). On successful login, sets session cookie and redirects to /. Also adds framework support for redirects and cookies in route handlers. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
AuthorizationDenied,
|
AuthorizationDenied,
|
||||||
type Call,
|
type Call,
|
||||||
type InternalHandler,
|
type InternalHandler,
|
||||||
|
isRedirect,
|
||||||
type Method,
|
type Method,
|
||||||
massageMethod,
|
massageMethod,
|
||||||
methodParser,
|
methodParser,
|
||||||
@@ -25,8 +26,9 @@ import {
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Parse JSON request bodies
|
// Parse request bodies
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
services.logging.log({ source: "logging", text: ["1"] });
|
services.logging.log({ source: "logging", text: ["1"] });
|
||||||
const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
|
const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
|
||||||
@@ -111,8 +113,10 @@ async function handler(
|
|||||||
const method = await methodParser.parseAsync(req.method);
|
const method = await methodParser.parseAsync(req.method);
|
||||||
|
|
||||||
const byMethod = processedRoutes[method];
|
const byMethod = processedRoutes[method];
|
||||||
|
console.log("DEBUG: req.path =", JSON.stringify(req.path), "method =", method);
|
||||||
for (const [_idx, pr] of byMethod.entries()) {
|
for (const [_idx, pr] of byMethod.entries()) {
|
||||||
const match = pr.matcher(req.url);
|
const match = pr.matcher(req.path);
|
||||||
|
console.log("DEBUG: trying pattern, match result =", match);
|
||||||
if (match) {
|
if (match) {
|
||||||
console.log("match", match);
|
console.log("match", match);
|
||||||
const resp = await pr.handler(req);
|
const resp = await pr.handler(req);
|
||||||
@@ -124,7 +128,7 @@ async function handler(
|
|||||||
const retval: Result = {
|
const retval: Result = {
|
||||||
code: httpCodes.clientErrors.NotFound,
|
code: httpCodes.clientErrors.NotFound,
|
||||||
contentType: contentTypes.text.plain,
|
contentType: contentTypes.text.plain,
|
||||||
result: "not found",
|
result: "not found!",
|
||||||
};
|
};
|
||||||
|
|
||||||
return retval;
|
return retval;
|
||||||
@@ -138,7 +142,18 @@ app.use(async (req: ExpressRequest, res: ExpressResponse) => {
|
|||||||
|
|
||||||
console.log(result);
|
console.log(result);
|
||||||
|
|
||||||
res.status(code).send(result);
|
// Set any cookies from the result
|
||||||
|
if (result0.cookies) {
|
||||||
|
for (const cookie of result0.cookies) {
|
||||||
|
res.cookie(cookie.name, cookie.value, cookie.options ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRedirect(result0)) {
|
||||||
|
res.redirect(code, result0.redirect);
|
||||||
|
} else {
|
||||||
|
res.status(code).send(result);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
process.title = `diachron:${cli.listen.port}`;
|
process.title = `diachron:${cli.listen.port}`;
|
||||||
|
|||||||
62
express/basic/login.ts
Normal file
62
express/basic/login.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { SESSION_COOKIE_NAME } from "../auth/token";
|
||||||
|
import { tokenLifetimes } from "../auth/types";
|
||||||
|
import { services } from "../services";
|
||||||
|
import type { Call, Result, Route } from "../types";
|
||||||
|
import { html, redirect, render } from "../util";
|
||||||
|
|
||||||
|
const loginHandler = async (call: Call): Promise<Result> => {
|
||||||
|
if (call.method === "GET") {
|
||||||
|
const c = await render("basic/login", {});
|
||||||
|
return html(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - handle login
|
||||||
|
const { email, password } = call.request.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
const c = await render("basic/login", {
|
||||||
|
error: "Email and password are required",
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
return html(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await services.auth.login(email, password, "cookie", {
|
||||||
|
userAgent: call.request.get("User-Agent"),
|
||||||
|
ipAddress: call.request.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const c = await render("basic/login", {
|
||||||
|
error: result.error,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
return html(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - set cookie and redirect to home
|
||||||
|
const redirectResult = redirect("/");
|
||||||
|
redirectResult.cookies = [
|
||||||
|
{
|
||||||
|
name: SESSION_COOKIE_NAME,
|
||||||
|
value: result.token,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false, // Set to true in production with HTTPS
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: tokenLifetimes.session,
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return redirectResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginRoute: Route = {
|
||||||
|
path: "/login",
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
handler: loginHandler,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { loginRoute };
|
||||||
@@ -1,18 +1,29 @@
|
|||||||
import { DateTime } from "ts-luxon";
|
import { DateTime } from "ts-luxon";
|
||||||
import type { Call, Result, Route } from "../types";
|
import type { Call, Result, Route } from "../types";
|
||||||
import { html, render } from "../util";
|
import { html, render } from "../util";
|
||||||
|
import { loginRoute } from "./login";
|
||||||
|
|
||||||
const routes: Record<string, Route> = {
|
const routes: Record<string, Route> = {
|
||||||
hello: {
|
hello: {
|
||||||
path: "/hello",
|
path: "/hello",
|
||||||
methods: ["GET"],
|
methods: ["GET"],
|
||||||
handler: async (call: Call): Promise<Result> => {
|
handler: async (_call: Call): Promise<Result> => {
|
||||||
const now = DateTime.now();
|
const now = DateTime.now();
|
||||||
const c = await render("basic/hello", { now });
|
const c = await render("basic/hello", { now });
|
||||||
|
|
||||||
return html(c);
|
return html(c);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
home: {
|
||||||
|
path:'/',
|
||||||
|
methods:['GET'],
|
||||||
|
handler:async(_call:Call): Promise<Result> => {
|
||||||
|
const c = await render('basic/home')
|
||||||
|
|
||||||
|
return html(c)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
login: loginRoute,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { routes };
|
export { routes };
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ const okText = (result: string): Result => {
|
|||||||
|
|
||||||
const routes: Route[] = [
|
const routes: Route[] = [
|
||||||
...authRoutes,
|
...authRoutes,
|
||||||
|
basicRoutes.home,
|
||||||
basicRoutes.hello,
|
basicRoutes.hello,
|
||||||
|
basicRoutes.login,
|
||||||
{
|
{
|
||||||
path: "/slow",
|
path: "/slow",
|
||||||
methods: ["GET"],
|
methods: ["GET"],
|
||||||
|
|||||||
@@ -49,12 +49,35 @@ export type ProcessedRoute = {
|
|||||||
handler: InternalHandler;
|
handler: InternalHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CookieOptions = {
|
||||||
|
httpOnly?: boolean;
|
||||||
|
secure?: boolean;
|
||||||
|
sameSite?: "strict" | "lax" | "none";
|
||||||
|
maxAge?: number;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Cookie = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
options?: CookieOptions;
|
||||||
|
};
|
||||||
|
|
||||||
export type Result = {
|
export type Result = {
|
||||||
code: HttpCode;
|
code: HttpCode;
|
||||||
contentType: ContentType;
|
contentType: ContentType;
|
||||||
result: string;
|
result: string;
|
||||||
|
cookies?: Cookie[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RedirectResult = Result & {
|
||||||
|
redirect: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isRedirect(result: Result): result is RedirectResult {
|
||||||
|
return "redirect" in result;
|
||||||
|
}
|
||||||
|
|
||||||
export type Route = {
|
export type Route = {
|
||||||
path: string;
|
path: string;
|
||||||
methods: Method[];
|
methods: Method[];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import nunjucks from "nunjucks";
|
|||||||
import { contentTypes } from "./content-types";
|
import { contentTypes } from "./content-types";
|
||||||
import { executionContext } from "./execution-context";
|
import { executionContext } from "./execution-context";
|
||||||
import { httpCodes } from "./http-codes";
|
import { httpCodes } from "./http-codes";
|
||||||
import type { Result } from "./types";
|
import type { Result, RedirectResult } from "./types";
|
||||||
|
|
||||||
// FIXME: Handle the error here
|
// FIXME: Handle the error here
|
||||||
const loadFile = async (path: string): Promise<string> => {
|
const loadFile = async (path: string): Promise<string> => {
|
||||||
@@ -13,12 +13,14 @@ const loadFile = async (path: string): Promise<string> => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const render = async (path: string, ctx: object): Promise<string> => {
|
const render = async (path: string, ctx?: object): Promise<string> => {
|
||||||
const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`;
|
const fullPath = `${executionContext.diachron_root}/templates/${path}.html.njk`;
|
||||||
|
|
||||||
const template = await loadFile(fullPath);
|
const template = await loadFile(fullPath);
|
||||||
|
|
||||||
const retval = nunjucks.renderString(template, ctx);
|
const c = ctx===undefined ? {} : ctx;
|
||||||
|
|
||||||
|
const retval = nunjucks.renderString(template, c);
|
||||||
|
|
||||||
return retval;
|
return retval;
|
||||||
};
|
};
|
||||||
@@ -33,4 +35,13 @@ const html = (payload: string): Result => {
|
|||||||
return retval;
|
return retval;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { render, html };
|
const redirect = (location: string): RedirectResult => {
|
||||||
|
return {
|
||||||
|
code: httpCodes.redirection.SeeOther,
|
||||||
|
contentType: contentTypes.text.plain,
|
||||||
|
result: "",
|
||||||
|
redirect: location,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { render, html, redirect };
|
||||||
|
|||||||
55
templates/basic/login.html.njk
Normal file
55
templates/basic/login.html.njk
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Login</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 50px auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fee;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Login</h1>
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" required value="{{ email | default('') }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user