feat(auth): Base auth with plugins
- Add fastify to protect routes plugins (requireAuth: true) - Simple Demo to show regular password auth (no 2FA/OTP nor remote auth) - Currently supports: login, logout, signin - OTP workflow should work, not tested - Fixed convention for docker volumes (now all placed in /volumes/<name>)
This commit is contained in:
parent
ddde700494
commit
964fe908a6
17 changed files with 398 additions and 197 deletions
|
|
@ -4,7 +4,8 @@ set -e
|
|||
set -x
|
||||
# do anything here
|
||||
|
||||
cp -r /extra /files
|
||||
mkdir -p /volumes/static/auth/
|
||||
cp -r /extra/login_demo.html /volumes/static/auth/index.html
|
||||
|
||||
# run the CMD [ ... ] from the dockerfile
|
||||
exec "$@"
|
||||
|
|
|
|||
84
src/auth/extra/login_demo.html
Normal file
84
src/auth/extra/login_demo.html
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<html>
|
||||
<head>
|
||||
<title> Demo Page For Login :) </title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1> Welcome <span id="t-username"> </span> </h1>
|
||||
|
||||
<input id="i-username" type="text" placeholder="Username"> </input>
|
||||
<input id="i-password" type="text" placeholder="Password"> </input>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<button id="b-login"> Login </button>
|
||||
<br />
|
||||
<button id="b-logout"> Logout </button>
|
||||
<br />
|
||||
<button id="b-signin"> Signin </button>
|
||||
<br />
|
||||
<button id="b-whoami"> Whoami </button>
|
||||
|
||||
<div id="d-response">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
const tUsername = document.querySelector("#t-username")
|
||||
|
||||
const iUsername = document.querySelector("#i-username");
|
||||
const iPassword = document.querySelector("#i-password");
|
||||
|
||||
const bLogin = document.querySelector("#b-login");
|
||||
const bLogout = document.querySelector("#b-logout");
|
||||
const bSignin = document.querySelector("#b-signin");
|
||||
const bWhoami = document.querySelector("#b-whoami");
|
||||
|
||||
const dResponse = document.querySelector("#d-response");
|
||||
|
||||
bWhoami.addEventListener("click", async () => {
|
||||
let username = "";
|
||||
try {
|
||||
let res = await fetch("/api/auth/whoami");
|
||||
const json = await res.json();
|
||||
if (json?.kind === "success")
|
||||
username = json?.payload?.name;
|
||||
else
|
||||
username = `<not logged in: ${json.msg}>`
|
||||
} catch {
|
||||
username = `<not logged in: threw>`
|
||||
}
|
||||
tUsername.innerText = username;
|
||||
}, 1000);
|
||||
|
||||
bLogin.addEventListener("click", async () => {
|
||||
const name = iUsername.value;
|
||||
const password = iPassword.value;
|
||||
|
||||
let res = await fetch("/api/auth/login", {method: "POST", body: JSON.stringify({name, password}), headers});
|
||||
let j = await res.json();
|
||||
if (j?.payload?.token)
|
||||
document.cookie = `token=${j?.payload?.token}`;
|
||||
dResponse.innerText = JSON.stringify(j, space=4);
|
||||
})
|
||||
bLogout.addEventListener("click", async () => {
|
||||
let res = await fetch("/api/auth/logout", { method: "POST" });
|
||||
dResponse.innerText = `done - status:${res.status}`;
|
||||
})
|
||||
bSignin.addEventListener("click", async () => {
|
||||
const name = iUsername.value;
|
||||
const password = iPassword.value;
|
||||
|
||||
let res = await fetch("/api/auth/signin", {method: "POST", body: JSON.stringify({name, password}), headers});
|
||||
let j = await res.json();
|
||||
if (j?.payload?.token)
|
||||
document.cookie = `token=${j?.payload?.token};`;
|
||||
dResponse.innerText = JSON.stringify(j, space=4);
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -26,7 +26,6 @@
|
|||
"fastify": "^5.0.0",
|
||||
"fastify-cli": "^7.4.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"sharp": "^0.34.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ const app: FastifyPluginAsync = async (
|
|||
fastify,
|
||||
opts
|
||||
): Promise<void> => {
|
||||
await fastify.register(db.useDatabase as any, {})
|
||||
await fastify.register(auth.jwtPlugin as any, {})
|
||||
await fastify.register(auth.authPlugin as any, {})
|
||||
|
||||
// Place here your custom code!
|
||||
for (const plugin of Object.values(plugins)) {
|
||||
void fastify.register(plugin as any, {});
|
||||
|
|
@ -31,20 +35,8 @@ const app: FastifyPluginAsync = async (
|
|||
void fastify.register(route as any, {});
|
||||
}
|
||||
|
||||
await fastify.register(db.useDatabase as any, {})
|
||||
await fastify.register(auth.jwtPlugin as any, {})
|
||||
void fastify.register(fastifyFormBody, {})
|
||||
void fastify.register(fastifyMultipart, {})
|
||||
console.log(fastify.db.getUser(0 as any));
|
||||
|
||||
// The use of fastify-plugin is required to be able
|
||||
// to export the decorators to the outer scope
|
||||
void fastify.register(fp(async (fastify) => {
|
||||
const image_store = process.env.USER_ICONS_STORE ?? "/tmp/icons";
|
||||
fastify.decorate('image_store', image_store)
|
||||
await mkdir(fastify.image_store, { recursive: true })
|
||||
}))
|
||||
|
||||
}
|
||||
|
||||
export default app
|
||||
|
|
|
|||
|
|
@ -3,74 +3,56 @@ import { FastifyPluginAsync } from "fastify";
|
|||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { user as userDb } from "@shared/database";
|
||||
import type { } from "@shared/auth";
|
||||
import { typeResponse, makeResponse } from "@shared/utils"
|
||||
|
||||
export const LoginReq = Type.Object({
|
||||
name: Type.String(),
|
||||
password: Type.String({ minLength: 8, maxLength: 32 }),
|
||||
password: Type.String(),
|
||||
});
|
||||
|
||||
export type LoginReq = Static<typeof LoginReq>;
|
||||
|
||||
|
||||
export const LoginRes = Type.Union([
|
||||
Type.Object({
|
||||
kind: Type.Const("failed"),
|
||||
msg_key: Type.Union([
|
||||
Type.Const("login.failed.generic"),
|
||||
Type.Const("login.failed.invalid"),
|
||||
]),
|
||||
}),
|
||||
Type.Object({
|
||||
kind: Type.Const("otpRequired"),
|
||||
msg_key: Type.Const("login.otpRequired"),
|
||||
token: Type.String({
|
||||
description: "Code to send with the OTP to finish login",
|
||||
}),
|
||||
}),
|
||||
Type.Object({
|
||||
kind: Type.Const("success"),
|
||||
msg_key: Type.Const("login.success"),
|
||||
token: Type.String({ description: "The JWT token" }),
|
||||
}),
|
||||
typeResponse("failed", ["login.failed.generic", "login.failed.invalid"]),
|
||||
typeResponse("otpRequired", "login.otpRequired", { token: Type.String({ description: "JWT to send with the OTP to finish login" }) }),
|
||||
typeResponse("success", "login.success", { token: Type.String({ description: "JWT that represent a logged in user" }) }),
|
||||
]);
|
||||
|
||||
|
||||
export type LoginRes = Static<typeof LoginRes>;
|
||||
|
||||
const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.post<{ Body: LoginReq; Response: LoginRes }>(
|
||||
"/login",
|
||||
{
|
||||
schema: {
|
||||
body: LoginReq,
|
||||
response: { "2xx": LoginRes },
|
||||
},
|
||||
},
|
||||
"/api/auth/login",
|
||||
{ schema: { body: LoginReq, response: { "2xx": LoginRes } }, },
|
||||
async function(req, res) {
|
||||
try {
|
||||
let { name, password } = req.body;
|
||||
console.log("HELLOO FDP");
|
||||
let user = this.db.getUserFromName(name);
|
||||
|
||||
// does the user exist
|
||||
// does it have a password setup ?
|
||||
if (user === null || user.password === null)
|
||||
return { kind: "failed", msg_key: "login.failed.invalid" };
|
||||
return makeResponse("failed", "login.failed.invalid");
|
||||
|
||||
// does the password he provided match the one we have
|
||||
if (!(await userDb.verifyUserPassword(user, password)))
|
||||
return { kind: "failed", msg_key: "login.failed.invalid" };
|
||||
return makeResponse("failed", "login.failed.invalid");
|
||||
|
||||
// does the user has 2FA up ?
|
||||
if (user.otp !== null) {
|
||||
if (user.otp !== undefined) {
|
||||
console.log(user);
|
||||
// yes -> we ask them to fill it,
|
||||
// send them somehting to verify that they indeed passed throught the user+password phase
|
||||
return { kind: "otpRequired", msg_key: "login.otpRequired", token: this.signJwt("otp", user.name) };
|
||||
return makeResponse("otpRequired", "login.otpRequired", { token: this.signJwt("otp", user.name) });
|
||||
}
|
||||
|
||||
// every check has been passed, they are now logged in, using this token to say who they are...
|
||||
return { kind: "success", msg_key: "login.success", token: this.signJwt("auth", user.name) }
|
||||
return makeResponse("success", "login.success", { token: this.signJwt("auth", user.name) });
|
||||
}
|
||||
catch {
|
||||
return { kind: "failed", msg_key: "login.failed.generic" };
|
||||
return makeResponse("failed", "login.failed.generic");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
12
src/auth/src/routes/logout.ts
Normal file
12
src/auth/src/routes/logout.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { FastifyPluginAsync } from "fastify";
|
||||
|
||||
const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.post(
|
||||
"/api/auth/logout",
|
||||
async function(req, res) {
|
||||
return res.clearCookie("token").send("bye :(")
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export default route;
|
||||
|
|
@ -2,37 +2,27 @@ import { FastifyPluginAsync } from "fastify";
|
|||
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { JwtType, Otp } from "@shared/auth";
|
||||
import { typeResponse, makeResponse } from "@shared/utils";
|
||||
|
||||
export const OtpReq = Type.Object({
|
||||
const OtpReq = Type.Object({
|
||||
token: Type.String({ description: "The token given at the login phase" }),
|
||||
code: Type.String({ description: "The OTP given by the user" }),
|
||||
});
|
||||
|
||||
export type OtpReq = Static<typeof OtpReq>;
|
||||
type OtpReq = Static<typeof OtpReq>;
|
||||
|
||||
export const OtpRes = Type.Union([
|
||||
Type.Object({
|
||||
kind: Type.Const("failed"),
|
||||
msg_key: Type.Union([
|
||||
Type.Const("otp.failed.generic"),
|
||||
Type.Const("otp.failed.invalid"),
|
||||
Type.Const("otp.failed.timeout"),
|
||||
]),
|
||||
}),
|
||||
Type.Object({
|
||||
kind: Type.Const("success"),
|
||||
msg_key: Type.Const("otp.success"),
|
||||
token: Type.String({ description: "The JWT token" }),
|
||||
}),
|
||||
const OtpRes = Type.Union([
|
||||
typeResponse("failed", ["otp.failed.generic", "otp.failed.invalid", "otp.failed.timeout"]),
|
||||
typeResponse("success", "otp.success", { token: Type.String({ description: "the JWT Token" }) }),
|
||||
]);
|
||||
|
||||
export type OtpRes = Static<typeof OtpRes>;
|
||||
type OtpRes = Static<typeof OtpRes>;
|
||||
|
||||
const OTP_TOKEN_TIMEOUT_SEC = 120;
|
||||
|
||||
const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.get<{ Body: OtpReq }>(
|
||||
"/whoami",
|
||||
fastify.post<{ Body: OtpReq }>(
|
||||
"/api/auth/otp",
|
||||
{ schema: { body: OtpReq, response: { "2xx": OtpRes } } },
|
||||
async function(req, res) {
|
||||
try {
|
||||
|
|
@ -43,18 +33,18 @@ const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
|||
// is the jwt a valid `otp` jwt ?
|
||||
if (dJwt.kind != "otp")
|
||||
// no ? fuck off then
|
||||
return { kind: "failed", msg_key: "otp.failed.invalid" };
|
||||
return makeResponse("failed", "otp.failed.invalid");
|
||||
// is it too old ?
|
||||
if (dJwt.createdAt + OTP_TOKEN_TIMEOUT_SEC * 1000 > Date.now())
|
||||
// yes ? fuck off then, redo the password
|
||||
return { kind: "failed", msg_key: "otp.failed.timeout" };
|
||||
return makeResponse("failed", "otp.failed.timeout");
|
||||
|
||||
// get the Otp sercret from the db
|
||||
let otpSecret = this.db.getUserFromName(dJwt.who)?.otp;
|
||||
if (otpSecret === null)
|
||||
// oops, either no user, or user without otpSecret
|
||||
// fuck off
|
||||
return { kind: "failed", msg_key: "otp.failed.invalid" };
|
||||
return makeResponse("failed", "otp.failed.invalid");
|
||||
|
||||
// good lets now verify the token you gave us is the correct one...
|
||||
let otpHandle = new Otp({ secret: otpSecret });
|
||||
|
|
@ -71,13 +61,9 @@ const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
|||
if (tokens.some((c) => c === code))
|
||||
// they do !
|
||||
// gg you are now logged in !
|
||||
return {
|
||||
kind: "success",
|
||||
msg_key: "otp.success",
|
||||
token: this.signJwt("auth", dJwt.who),
|
||||
};
|
||||
return makeResponse("success", "otp.success", { token: this.signJwt("auth", dJwt.who) });
|
||||
} catch {
|
||||
return { kind: "failed", msg_key: "otp.failed.generic" };
|
||||
return makeResponse("failed", "otp.failed.generic");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,97 +1,63 @@
|
|||
import { FastifyPluginAsync } from "fastify";
|
||||
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { typeResponse, makeResponse } from "@shared/utils";
|
||||
|
||||
const USERNAME_CHECK: RegExp = /^[a-zA-Z\_0-9]+$/;
|
||||
|
||||
export const SignInReq = Type.Object({
|
||||
const SignInReq = Type.Object({
|
||||
name: Type.String(),
|
||||
password: Type.String({ minLength: 8, maxLength: 32 }),
|
||||
password: Type.String(),
|
||||
});
|
||||
|
||||
export type SignInReq = Static<typeof SignInReq>;
|
||||
type SignInReq = Static<typeof SignInReq>;
|
||||
|
||||
export const SignInRes = Type.Union([
|
||||
Type.Object({
|
||||
kind: Type.Const("failed"),
|
||||
msg_key: Type.Union([
|
||||
Type.Const("signin.failed.generic"),
|
||||
Type.Const("signin.failed.username.existing"),
|
||||
Type.Const("signin.failed.username.toolong"),
|
||||
Type.Const("signin.failed.username.tooshort"),
|
||||
Type.Const("signin.failed.username.invalid"),
|
||||
Type.Const("signin.failed.password.toolong"),
|
||||
Type.Const("signin.failed.password.tooshort"),
|
||||
Type.Const("signin.failed.password.invalid"),
|
||||
]),
|
||||
}),
|
||||
Type.Object({
|
||||
kind: Type.Const("sucess"),
|
||||
msg_key: Type.Const("signin.sucess"),
|
||||
token: Type.String({ description: "The JWT token" }),
|
||||
}),
|
||||
]);
|
||||
const SignInRes = Type.Union([
|
||||
typeResponse("failed", [
|
||||
"signin.failed.generic",
|
||||
"signin.failed.username.existing",
|
||||
"signin.failed.username.toolong",
|
||||
"signin.failed.username.tooshort",
|
||||
"signin.failed.username.invalid",
|
||||
"signin.failed.password.toolong",
|
||||
"signin.failed.password.tooshort",
|
||||
"signin.failed.password.invalid",
|
||||
]),
|
||||
typeResponse("success", "signin.success", { token: Type.String({ description: "the JWT token" }) }),
|
||||
])
|
||||
|
||||
export type SignInRes = Static<typeof SignInRes>;
|
||||
type SignInRes = Static<typeof SignInRes>;
|
||||
|
||||
const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.post<{ Body: SignInReq; Response: SignInRes }>(
|
||||
"/signin",
|
||||
{ schema: { body: SignInReq, response: { "2xx": SignInRes } } },
|
||||
fastify.post<{ Body: SignInReq }>(
|
||||
"/api/auth/signin",
|
||||
{ schema: { body: SignInReq, response: { "200": SignInRes, "5xx": Type.Object({}) } }, },
|
||||
async function(req, res) {
|
||||
const { name, password } = req.body;
|
||||
|
||||
if (name.length < 4)
|
||||
return {
|
||||
kind: "failed",
|
||||
msg_key: "signin.failed.username.tooshort",
|
||||
};
|
||||
return makeResponse("failed", "signin.failed.username.tooshort");
|
||||
if (name.length > 32)
|
||||
return {
|
||||
kind: "failed",
|
||||
msg_key: "signin.failed.username.toolong",
|
||||
};
|
||||
return makeResponse("failed", "signin.failed.username.toolong");
|
||||
if (!USERNAME_CHECK.test(name))
|
||||
return {
|
||||
kind: "failed",
|
||||
msg_key: "signin.failed.username.invalid",
|
||||
};
|
||||
return makeResponse("failed", "signin.failed.username.invalid");
|
||||
// username if good now :)
|
||||
|
||||
if (password.length < 8)
|
||||
return {
|
||||
kind: "failed",
|
||||
msg_key: "signin.failed.password.tooshort",
|
||||
};
|
||||
return makeResponse("failed", "signin.failed.password.tooshort");
|
||||
if (password.length > 64)
|
||||
return {
|
||||
kind: "failed",
|
||||
msg_key: "signin.failed.password.toolong",
|
||||
};
|
||||
return makeResponse("failed", "signin.failed.password.toolong");
|
||||
// password is good too !
|
||||
|
||||
if (this.db.getUserFromName(name) !== null)
|
||||
return {
|
||||
kind: "failed",
|
||||
msg_key: "signin.failed.username.existing",
|
||||
};
|
||||
return makeResponse("failed", "signin.failed.username.existing");
|
||||
let u = await this.db.createUser(name, password);
|
||||
if (u === null)
|
||||
return { kind: "failed", msg_key: "signin.failed.generic" };
|
||||
return makeResponse("failed", "signin.failed.generic");
|
||||
|
||||
// every check has been passed, they are now logged in, using this token to say who they are...
|
||||
let userToken = this.jwt.sign({
|
||||
kind: "auth",
|
||||
user: u.name,
|
||||
createAt: Date.now() / 1000,
|
||||
});
|
||||
let out = {
|
||||
kind: "success",
|
||||
msg_key: "login.success",
|
||||
token: userToken,
|
||||
};
|
||||
console.log(out)
|
||||
return out;
|
||||
let userToken = this.signJwt('auth', u.name);
|
||||
return makeResponse("success", "signin.success", { token: userToken });
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import { FastifyPluginAsync } from "fastify";
|
||||
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { makeResponse, typeResponse } from "@shared/utils"
|
||||
|
||||
|
||||
export const WhoAmIRes = Type.String({ description: "username" });
|
||||
export const WhoAmIRes = typeResponse("success", "whoami.success", { name: Type.String() });
|
||||
|
||||
export type WhoAmIRes = Static<typeof WhoAmIRes>;
|
||||
|
||||
const route: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
||||
fastify.get(
|
||||
"/whoami",
|
||||
{ schema: { response: { "2xx": WhoAmIRes } } },
|
||||
"/api/auth/whoami",
|
||||
{ schema: { response: { "2xx": WhoAmIRes } }, config: { requireAuth: true } },
|
||||
async function(req, res) {
|
||||
return "yes";
|
||||
return makeResponse("success", "whoami.success", { name: req.authUser?.name })
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue