From a7c753f38b66d34e89979dabc3373eb2e4f81021 Mon Sep 17 00:00:00 2001 From: Maieul BOYER Date: Sun, 31 Aug 2025 16:27:42 +0200 Subject: [PATCH] feat(auth): Added 2FA/OTP manage endpoints - CodeWise: Changed everything to use undefined when not present - CodeWise: checks for nonpresent value using `isNullish` - enableOtp: enable Otp, return topt url. Does nothing when already enabled - disableOtp: disable 2FA Totp for the user - statusOtp: get the 2FA status for the user. return the Totp Url if enabled - loginDemo: split into two files - loginDemo: supports for 2FA - loginDemo: better response box --- src/@shared/src/auth/index.ts | 8 +- src/@shared/src/database/index.ts | 4 +- src/@shared/src/database/mixin/_template.ts | 16 +-- src/@shared/src/database/mixin/user.ts | 104 +++++++++--------- src/@shared/src/utils/index.ts | 15 +++ src/auth/entrypoint.sh | 1 + src/auth/extra/login_demo.html | 112 ++++++-------------- src/auth/extra/login_demo.js | 107 +++++++++++++++++++ src/auth/src/routes/disableOtp.ts | 27 +++++ src/auth/src/routes/enableOtp.ts | 31 ++++++ src/auth/src/routes/login.ts | 16 ++- src/auth/src/routes/logout.ts | 6 +- src/auth/src/routes/otp.ts | 13 +-- src/auth/src/routes/signin.ts | 6 +- src/auth/src/routes/statusOtp.ts | 32 ++++++ src/auth/src/routes/whoami.ts | 15 ++- src/icons/src/routes/set.ts | 3 +- 17 files changed, 341 insertions(+), 175 deletions(-) create mode 100644 src/auth/extra/login_demo.js create mode 100644 src/auth/src/routes/disableOtp.ts create mode 100644 src/auth/src/routes/enableOtp.ts create mode 100644 src/auth/src/routes/statusOtp.ts diff --git a/src/@shared/src/auth/index.ts b/src/@shared/src/auth/index.ts index f79835e..6a80b8a 100644 --- a/src/@shared/src/auth/index.ts +++ b/src/@shared/src/auth/index.ts @@ -6,7 +6,7 @@ import { FastifyPluginAsync, preValidationAsyncHookHandler } from "fastify"; import { Static, Type } from "@sinclair/typebox"; import { UserId } from "@shared/database/mixin/user"; import { useDatabase } from "@shared/database"; -import { makeResponse } from "@shared/utils"; +import { isNullish, makeResponse } from "@shared/utils"; const kRouteAuthDone = Symbol("shared-route-auth-done"); @@ -33,7 +33,7 @@ let jwtAdded = false; export const jwtPlugin = fp(async (fastify, _opts) => { if (jwtAdded) jwtAdded = true; let env = process.env.JWT_SECRET; - if (env === undefined || env === null) throw "JWT_SECRET is not defined"; + if (isNullish(env)) throw "JWT_SECRET is not defined"; if (!fastify.hasDecorator("signJwt")) { void fastify.decorate("signJwt", (kind, who) => fastify.jwt.sign({ kind, who, createdAt: Date.now() }), @@ -78,7 +78,7 @@ export const authPlugin = fp(async (fastify, _opts) => { ) { let f: preValidationAsyncHookHandler = async function(req, res) { try { - if (req.cookies.token === undefined) + if (isNullish(req.cookies.token)) return res .clearCookie("token") .send( @@ -92,7 +92,7 @@ export const authPlugin = fp(async (fastify, _opts) => { JSON.stringify(makeResponse("notLoggedIn", "auth.invalidKind")), ); let user = this.db.getUserFromName(tok.who); - if (user === null) + if (isNullish(user)) return res .clearCookie("token") .send( diff --git a/src/@shared/src/database/index.ts b/src/@shared/src/database/index.ts index d684f7c..d77b176 100644 --- a/src/@shared/src/database/index.ts +++ b/src/@shared/src/database/index.ts @@ -3,6 +3,7 @@ import { FastifyInstance, FastifyPluginAsync } from 'fastify' import { Database as DbImpl } from "./mixin/_base"; import { UserImpl, IUserDb } from "./mixin/user"; +import { isNullish } from '@shared/utils'; Object.assign(DbImpl.prototype, UserImpl); @@ -25,7 +26,7 @@ export const useDatabase = fp(async function( return; dbAdded = true; let path = process.env.DATABASE_DIR; - if (path === null || path === undefined) + if (isNullish(path)) throw "env `DATABASE_DIR` not defined"; f.log.info(`Opening database with path: ${path}/database.db`) let db: Database = new DbImpl(`${path}/database.db`) as Database; @@ -33,6 +34,5 @@ export const useDatabase = fp(async function( f.decorate('db', db); }); -export * as user from "./mixin/user" export default useDatabase; diff --git a/src/@shared/src/database/mixin/_template.ts b/src/@shared/src/database/mixin/_template.ts index bf459ed..05ced1e 100644 --- a/src/@shared/src/database/mixin/_template.ts +++ b/src/@shared/src/database/mixin/_template.ts @@ -4,8 +4,8 @@ import type { Database } from "./_base"; // describe every function in the object export interface ITemplateDb extends Database { - normalFunction(id: TemplateId): TemplateData | null, - asyncFunction(id: TemplateId): Promise, + normalFunction(id: TemplateId): TemplateData | undefined, + asyncFunction(id: TemplateId): Promise, }; export const UserImpl: Omit = { @@ -16,9 +16,9 @@ export const UserImpl: Omit = { * * @returns what does the function return ? */ - normalFunction(this: ITemplateDb, id: TemplateId): TemplateData | null { + normalFunction(this: ITemplateDb, id: TemplateId): TemplateData | undefined { void id; - return null; + return undefined; }, /** * whole function description @@ -27,9 +27,9 @@ export const UserImpl: Omit = { * * @returns what does the function return ? */ - async asyncFunction(this: ITemplateDb, id: TemplateId): Promise { + async asyncFunction(this: ITemplateDb, id: TemplateId): Promise { void id; - return null; + return undefined; }, }; @@ -47,8 +47,8 @@ export async function freeFloatingExportedFunction(): Promise { } // this function will never be able to be called outside of this module -async function privateFunction(): Promise { - return null +async function privateFunction(): Promise { + return undefined } //silence warnings diff --git a/src/@shared/src/database/mixin/user.ts b/src/@shared/src/database/mixin/user.ts index 189fdc3..921e773 100644 --- a/src/@shared/src/database/mixin/user.ts +++ b/src/@shared/src/database/mixin/user.ts @@ -1,15 +1,19 @@ import type { Database, SqliteReturn } from "./_base"; +import { Otp } from "@shared/auth"; +import { isNullish } from "@shared/utils"; import * as bcrypt from "bcrypt"; // never use this directly export interface IUserDb extends Database { - getUser(id: UserId): User | null, - getUserFromName(name: string): User | null, - getUserFromRawId(id: number): User | null, - getUserOtpSecret(id: UserId): string | null, - createUser(name: string, password: string | null): Promise, - setUserPassword(id: UserId, password: string | null): Promise, + getUser(id: UserId): User | undefined, + getUserFromName(name: string): User | undefined, + getUserFromRawId(id: number): User | undefined, + getUserOtpSecret(id: UserId): string | undefined, + createUser(name: string, password: string | undefined): Promise, + setUserPassword(id: UserId, password: string | undefined): Promise, + ensureUserOtpSecret(id: UserId): string | undefined, + deleteUserOtpSecret(id: UserId): void, }; export const UserImpl: Omit = { @@ -18,9 +22,9 @@ export const UserImpl: Omit = { * * @param id the userid to fetch * - * @returns The user if it exists, null otherwise + * @returns The user if it exists, undefined otherwise */ - getUser(this: IUserDb, id: UserId): User | null { + getUser(this: IUserDb, id: UserId): User | undefined { return this.getUserFromRawId(id); }, @@ -29,9 +33,9 @@ export const UserImpl: Omit = { * * @param name the username to fetch * - * @returns The user if it exists, null otherwise + * @returns The user if it exists, undefined otherwise */ - getUserFromName(this: IUserDb, name: string): User | null { + getUserFromName(this: IUserDb, name: string): User | undefined { return userFromRow( this.prepare( "SELECT * FROM user WHERE name = @name LIMIT 1", @@ -44,9 +48,9 @@ export const UserImpl: Omit = { * * @param id the userid to modify * - * @returns The user if it exists, null otherwise + * @returns The user if it exists, undefined otherwise */ - getUserFromRawId(this: IUserDb, id: number): User | null { + getUserFromRawId(this: IUserDb, id: number): User | undefined { return userFromRow( this.prepare("SELECT * FROM user WHERE id = @id LIMIT 1").get({ id, @@ -62,7 +66,7 @@ export const UserImpl: Omit = { * * @returns The user struct */ - async createUser(this: IUserDb, name: string, password: string | null): Promise { + async createUser(this: IUserDb, name: string, password: string | undefined): Promise { password = await hashPassword(password); return userFromRow( this.prepare( @@ -76,11 +80,11 @@ export const UserImpl: Omit = { * You are required to hash the password before storing it in the database * * @param id the userid to modify - * @param password the plaintext password to store (can be null to remove password login) + * @param password the plaintext password to store (can be undefined to remove password login) * - * @returns The modified user if it exists, null otherwise + * @returns The modified user if it exists, undefined otherwise */ - async setUserPassword(this: IUserDb, id: UserId, password: string | null): Promise { + async setUserPassword(this: IUserDb, id: UserId, password: string | undefined): Promise { password = await hashPassword(password); return userFromRow( this.prepare( @@ -89,12 +93,27 @@ export const UserImpl: Omit = { ); }, - getUserOtpSecret(this: IUserDb, id: UserId): string | null { + getUserOtpSecret(this: IUserDb, id: UserId): string | undefined { let otp: any = this.prepare("SELECT otp FROM user WHERE id = @id LIMIT 1").get({ id }) as SqliteReturn; - console.log(otp); - if (otp?.otp === undefined || otp?.otp === null) return null; + if (isNullish(otp?.otp)) return undefined; return otp.otp; }, + + ensureUserOtpSecret(this: IUserDb, id: UserId): string | undefined { + let otp = this.getUserOtpSecret(id); + if (!isNullish(otp)) + return otp; + let otpGen = new Otp(); + const res: any = this.prepare("UPDATE OR IGNORE user SET otp = @otp WHERE id = @id RETURNING otp") + .get({ id, otp: otpGen.secret }); + console.log(res); + if (isNullish(res?.otp)) return undefined; + return res?.otp; + }, + + deleteUserOtpSecret(this: IUserDb, id: UserId): void { + this.prepare("UPDATE OR IGNORE user SET otp = NULL WHERE id = @id").run({ id }); + } }; export type UserId = number & { readonly __brand: unique symbol }; @@ -106,39 +125,13 @@ export type User = { readonly otp?: string; }; -/** - * Represent different state a "username" might be - * - * @enum V_valid The username is valid - * @enum E_tooShort The username is too short - * @enum E_tooLong The username is too long - * @enum E_invalChar the username contains invalid characters (must be alphanumeric) - * - */ -export const enum ValidUserNameRet { - V_valid = "username.valid", - E_tooShort = "username.tooShort", - E_tooLong = "username.toLong", - E_invalChar = "username.invalChar" -} - -export function validUserName(username: string): ValidUserNameRet { - if (username.length < 4) - return ValidUserNameRet.E_tooShort; - if (username.length > 16) - return ValidUserNameRet.E_tooLong; - if (!(RegExp("^[0-9a-zA-Z]$").test(username))) - return ValidUserNameRet.E_invalChar; - return ValidUserNameRet.V_valid; -} - export async function verifyUserPassword( user: User, password: string, ): Promise { // The user doesn't have a password, so it can't match. // This is somewhat bad thing to do, since it is a time-attack vector, but I don't care ? - if (user.password == null) return false; + if (isNullish(user.password)) return false; return await bcrypt.compare(password, user.password); } @@ -148,12 +141,12 @@ export async function verifyUserPassword( * @param password the plaintext password to hash (if any)\ * @returns the bcrypt hashed password * - * @note: This function will do nothing if [`null`] is passed (it'll return null directly) + * @note: This function will do nothing if [`undefined`] is passed (it'll return undefined directly) */ async function hashPassword( - password: string | null, -): Promise { - if (password === null) return null; + password: string | undefined, +): Promise { + if (isNullish(password)) return undefined; return await bcrypt.hash(password, 12); } @@ -162,13 +155,14 @@ async function hashPassword( * * @param row The data from sqlite * - * @returns The user if it exists, null otherwise + * @returns The user if it exists, undefined otherwise */ -function userFromRow(row: any): User | null { - if (row == null || row == undefined) return null; +function userFromRow(row: any): User | undefined { + if (isNullish(row)) return undefined; return { id: row.id as UserId, - name: row.name || null, - password: row.password || null, + name: row.name || undefined, + password: row.password || undefined, + otp: row.otp || undefined, }; } diff --git a/src/@shared/src/utils/index.ts b/src/@shared/src/utils/index.ts index b41b1df..350b93e 100644 --- a/src/@shared/src/utils/index.ts +++ b/src/@shared/src/utils/index.ts @@ -56,3 +56,18 @@ export function typeResponse(kind: string, key: MessageKey | MessageKey[], paylo return Type.Object(Ty) } + +/** + * @description returns weither a value is null or undefined + * + * @example assert_equal(isNullish(null), true); + * @example assert_equal(isNullish(undefined), true); + * @example assert_equal(isNullish(0), false); + * @example assert_equal(isNullish(""), false); + * @example assert_equal(isNullish([]), false); + * @example assert_equal(isNullish({}), false); + * @example assert_equal(isNullish(false), false); + */ +export function isNullish(v: T | undefined | null): v is (null | undefined) { + return v === null || v === undefined +} diff --git a/src/auth/entrypoint.sh b/src/auth/entrypoint.sh index 45ad91f..a5c6509 100644 --- a/src/auth/entrypoint.sh +++ b/src/auth/entrypoint.sh @@ -6,6 +6,7 @@ set -x mkdir -p /volumes/static/auth/ cp -r /extra/login_demo.html /volumes/static/auth/index.html +cp -r /extra/login_demo.js /volumes/static/auth/login_demo.js # run the CMD [ ... ] from the dockerfile exec "$@" diff --git a/src/auth/extra/login_demo.html b/src/auth/extra/login_demo.html index e731d1d..c7dae28 100644 --- a/src/auth/extra/login_demo.html +++ b/src/auth/extra/login_demo.html @@ -1,84 +1,38 @@ + - - Demo Page For Login :) - - -

Welcome

+ + Demo Page For Login :) + - - -
-
+ +

+ Welcome +

+ + + + +
+ + + +
+
+ +
+ +
+ +
+ +
+ + + +
+

+	
+
 
-		
-		
- -
- -
- - -
-
- - - diff --git a/src/auth/extra/login_demo.js b/src/auth/extra/login_demo.js new file mode 100644 index 0000000..188e749 --- /dev/null +++ b/src/auth/extra/login_demo.js @@ -0,0 +1,107 @@ +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 iOtp = document.querySelector("#i-otp"); + +const bOtpSend = document.querySelector("#b-otpSend"); +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 bOtpStatus = document.querySelector("#b-otpStatus"); +const bOtpEnable = document.querySelector("#b-otpEnable"); +const bOtpDisable = document.querySelector("#b-otpDisable"); + +const dResponse = document.querySelector("#d-response"); + +function setResponse(obj) { + let obj_str = JSON.stringify(obj, null, 4); + dResponse.innerText = obj_str; +} +let otpToken = null; + +bOtpSend.addEventListener("click", async () => { + let res = await fetch("/api/auth/otp", { method: "POST", body: JSON.stringify({ code: iOtp.value, token: otpToken }), headers }); + const json = await res.json(); + + setResponse(json); + if (json.kind === "success") { + if (json?.payload?.token) + document.cookie = `token=${json?.payload?.token}`; + } +}); + +bOtpStatus.addEventListener("click", async () => { + let res = await fetch("/api/auth/statusOtp"); + const json = await res.json(); + + setResponse(json); +}); + +bOtpEnable.addEventListener("click", async () => { + let res = await fetch("/api/auth/enableOtp", { method: "PUT" }); + const json = await res.json(); + + setResponse(json); +}); + +bOtpDisable.addEventListener("click", async () => { + let res = await fetch("/api/auth/disableOtp", { method: "PUT" }); + const json = await res.json(); + + setResponse(json); +}); + +bWhoami.addEventListener("click", async () => { + let username = ""; + try { + let res = await fetch("/api/auth/whoami"); + const json = await res.json(); + setResponse(json); + if (json?.kind === "success") + username = json?.payload?.name; + else + username = `` + } catch { + username = `` + } + tUsername.innerText = username; +}); + +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 json = await res.json(); + if (json?.kind === "otpRequired") { + otpToken = json?.payload?.token; + } else if (json?.kind === "success") { + if (json?.payload?.token) + document.cookie = `token=${json?.payload?.token}`; + } + setResponse(json); +}) + +bLogout.addEventListener("click", async () => { + let res = await fetch("/api/auth/logout", { method: "POST" }); + setResponse(await res.json()); +}) + +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 json = await res.json(); + if (json?.payload?.token) + document.cookie = `token=${json?.payload?.token};`; + setResponse(json); +}) diff --git a/src/auth/src/routes/disableOtp.ts b/src/auth/src/routes/disableOtp.ts new file mode 100644 index 0000000..7c09325 --- /dev/null +++ b/src/auth/src/routes/disableOtp.ts @@ -0,0 +1,27 @@ +import { FastifyPluginAsync } from "fastify"; + +import { Static, Type } from "@sinclair/typebox"; +import { makeResponse, typeResponse, isNullish } from "@shared/utils" + + +export const WhoAmIRes = Type.Union([ + typeResponse("success", "disableOtp.success"), + typeResponse("failure", "disableOtp.failure.generic") +]); + +export type WhoAmIRes = Static; + +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { + fastify.put( + "/api/auth/disableOtp", + { schema: { response: { "2xx": WhoAmIRes } }, config: { requireAuth: true } }, + async function(req, _res) { + if (isNullish(req.authUser)) + return makeResponse("failure", "disableOtp.failure.generic"); + this.db.deleteUserOtpSecret(req.authUser.id); + return makeResponse("success", "disableOtp.success"); + }, + ); +}; + +export default route; diff --git a/src/auth/src/routes/enableOtp.ts b/src/auth/src/routes/enableOtp.ts new file mode 100644 index 0000000..3d053f0 --- /dev/null +++ b/src/auth/src/routes/enableOtp.ts @@ -0,0 +1,31 @@ +import { FastifyPluginAsync } from "fastify"; + +import { Static, Type } from "@sinclair/typebox"; +import { isNullish, makeResponse, typeResponse } from "@shared/utils" +import { Otp } from "@shared/auth"; + + +export const WhoAmIRes = Type.Union([ + typeResponse("success", "enableOtp.success", { url: Type.String({ description: "The otp url to feed into a 2fa app" }) }), + typeResponse("failure", ["enableOtp.failure.noUser", "enableOtp.failure.noSecret"]) +]); + +export type WhoAmIRes = Static; + +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { + fastify.put( + "/api/auth/enableOtp", + { schema: { response: { "2xx": WhoAmIRes } }, config: { requireAuth: true } }, + async function(req, _res) { + if (isNullish(req.authUser)) + return makeResponse("failure", "enableOtp.failure.noUser"); + let otpSecret = this.db.ensureUserOtpSecret(req.authUser!.id); + if (isNullish(otpSecret)) + return makeResponse("failure", "enableOtp.failure.noSecret"); + let otp = new Otp({ secret: otpSecret }); + return makeResponse("success", "enableOtp.success", { url: otp.totpURL }); + }, + ); +}; + +export default route; diff --git a/src/auth/src/routes/login.ts b/src/auth/src/routes/login.ts index b0f679c..c869814 100644 --- a/src/auth/src/routes/login.ts +++ b/src/auth/src/routes/login.ts @@ -1,9 +1,8 @@ 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" +import { typeResponse, makeResponse, isNullish } from "@shared/utils" +import { verifyUserPassword } from "@shared/database/mixin/user"; export const LoginReq = Type.Object({ name: Type.String(), @@ -21,27 +20,26 @@ export const LoginRes = Type.Union([ export type LoginRes = Static; -const route: FastifyPluginAsync = async (fastify, opts): Promise => { +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { fastify.post<{ Body: LoginReq; Response: LoginRes }>( "/api/auth/login", { schema: { body: LoginReq, response: { "2xx": LoginRes } }, }, - async function(req, res) { + async function(req, _res) { try { let { name, password } = req.body; let user = this.db.getUserFromName(name); // does the user exist // does it have a password setup ? - if (user === null || user.password === null) + if (isNullish(user?.password)) return makeResponse("failed", "login.failed.invalid"); // does the password he provided match the one we have - if (!(await userDb.verifyUserPassword(user, password))) + if (!(await verifyUserPassword(user, password))) return makeResponse("failed", "login.failed.invalid"); // does the user has 2FA up ? - if (user.otp !== undefined) { - console.log(user); + if (!isNullish(user.otp)) { // yes -> we ask them to fill it, // send them somehting to verify that they indeed passed throught the user+password phase return makeResponse("otpRequired", "login.otpRequired", { token: this.signJwt("otp", user.name) }); diff --git a/src/auth/src/routes/logout.ts b/src/auth/src/routes/logout.ts index 3acd1d9..64a5734 100644 --- a/src/auth/src/routes/logout.ts +++ b/src/auth/src/routes/logout.ts @@ -1,10 +1,10 @@ import { FastifyPluginAsync } from "fastify"; -const route: FastifyPluginAsync = async (fastify, opts): Promise => { +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { fastify.post( "/api/auth/logout", - async function(req, res) { - return res.clearCookie("token").send("bye :(") + async function(_req, res) { + return res.clearCookie("token").send("{}") }, ); }; diff --git a/src/auth/src/routes/otp.ts b/src/auth/src/routes/otp.ts index 6f45662..e1f207b 100644 --- a/src/auth/src/routes/otp.ts +++ b/src/auth/src/routes/otp.ts @@ -2,7 +2,7 @@ import { FastifyPluginAsync } from "fastify"; import { Static, Type } from "@sinclair/typebox"; import { JwtType, Otp } from "@shared/auth"; -import { typeResponse, makeResponse } from "@shared/utils"; +import { typeResponse, makeResponse, isNullish } from "@shared/utils"; const OtpReq = Type.Object({ token: Type.String({ description: "The token given at the login phase" }), @@ -12,7 +12,7 @@ const OtpReq = Type.Object({ type OtpReq = Static; const OtpRes = Type.Union([ - typeResponse("failed", ["otp.failed.generic", "otp.failed.invalid", "otp.failed.timeout"]), + typeResponse("failed", ["otp.failed.generic", "otp.failed.invalid", "otp.failed.timeout", "otp.failed.noSecret"]), typeResponse("success", "otp.success", { token: Type.String({ description: "the JWT Token" }) }), ]); @@ -20,11 +20,11 @@ type OtpRes = Static; const OTP_TOKEN_TIMEOUT_SEC = 120; -const route: FastifyPluginAsync = async (fastify, opts): Promise => { +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { fastify.post<{ Body: OtpReq }>( "/api/auth/otp", { schema: { body: OtpReq, response: { "2xx": OtpRes } } }, - async function(req, res) { + async function(req, _res) { try { const { token, code } = req.body; // lets try to decode+verify the jwt @@ -41,10 +41,10 @@ const route: FastifyPluginAsync = async (fastify, opts): Promise => { // get the Otp sercret from the db let otpSecret = this.db.getUserFromName(dJwt.who)?.otp; - if (otpSecret === null) + if (isNullish(otpSecret)) // oops, either no user, or user without otpSecret // fuck off - return makeResponse("failed", "otp.failed.invalid"); + return makeResponse("failed", "otp.failed.noSecret"); // good lets now verify the token you gave us is the correct one... let otpHandle = new Otp({ secret: otpSecret }); @@ -65,6 +65,7 @@ const route: FastifyPluginAsync = async (fastify, opts): Promise => { } catch { return makeResponse("failed", "otp.failed.generic"); } + return makeResponse("failed", "otp.failed.generic"); }, ); }; diff --git a/src/auth/src/routes/signin.ts b/src/auth/src/routes/signin.ts index 38a1914..e6e84bb 100644 --- a/src/auth/src/routes/signin.ts +++ b/src/auth/src/routes/signin.ts @@ -1,7 +1,7 @@ import { FastifyPluginAsync } from "fastify"; import { Static, Type } from "@sinclair/typebox"; -import { typeResponse, makeResponse } from "@shared/utils"; +import { typeResponse, makeResponse, isNullish } from "@shared/utils"; const USERNAME_CHECK: RegExp = /^[a-zA-Z\_0-9]+$/; @@ -49,10 +49,10 @@ const route: FastifyPluginAsync = async (fastify, opts): Promise => { return makeResponse("failed", "signin.failed.password.toolong"); // password is good too ! - if (this.db.getUserFromName(name) !== null) + if (this.db.getUserFromName(name) !== undefined) return makeResponse("failed", "signin.failed.username.existing"); let u = await this.db.createUser(name, password); - if (u === null) + if (isNullish(u)) return makeResponse("failed", "signin.failed.generic"); // every check has been passed, they are now logged in, using this token to say who they are... diff --git a/src/auth/src/routes/statusOtp.ts b/src/auth/src/routes/statusOtp.ts new file mode 100644 index 0000000..d79fd1d --- /dev/null +++ b/src/auth/src/routes/statusOtp.ts @@ -0,0 +1,32 @@ +import { FastifyPluginAsync } from "fastify"; + +import { Static, Type } from "@sinclair/typebox"; +import { isNullish, makeResponse, typeResponse } from "@shared/utils" +import { Otp } from "@shared/auth"; + + +export const StatusOtpRes = Type.Union([ + typeResponse("success", "statusOtp.success.enabled", { url: Type.String({ description: "The otp url to feed into a 2fa app" }) }), + typeResponse("success", "statusOtp.success.disabled"), + typeResponse("failure", "statusOtp.failure.generic") +]); + +export type StatusOtpRes = Static; + +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { + fastify.get( + "/api/auth/statusOtp", + { schema: { response: { "2xx": StatusOtpRes } }, config: { requireAuth: true } }, + async function(req, _res) { + if (isNullish(req.authUser)) + return makeResponse("failure", "statusOtp.failure.generic"); + let otpSecret = this.db.getUserOtpSecret(req.authUser.id); + if (isNullish(otpSecret)) + return makeResponse("success", "statusOtp.success.disabled"); + let otp = new Otp({ secret: otpSecret }) + return makeResponse("success", "statusOtp.success.enabled", { url: otp.totpURL }); + }, + ); +}; + +export default route; diff --git a/src/auth/src/routes/whoami.ts b/src/auth/src/routes/whoami.ts index 8200541..2c16b49 100644 --- a/src/auth/src/routes/whoami.ts +++ b/src/auth/src/routes/whoami.ts @@ -1,19 +1,24 @@ import { FastifyPluginAsync } from "fastify"; import { Static, Type } from "@sinclair/typebox"; -import { makeResponse, typeResponse } from "@shared/utils" +import { isNullish, makeResponse, typeResponse } from "@shared/utils" -export const WhoAmIRes = typeResponse("success", "whoami.success", { name: Type.String() }); +export const WhoAmIRes = Type.Union([ + typeResponse("success", "whoami.success", { name: Type.String() }), + typeResponse("failure", "whoami.failure.generic") +]); export type WhoAmIRes = Static; -const route: FastifyPluginAsync = async (fastify, opts): Promise => { +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { fastify.get( "/api/auth/whoami", { schema: { response: { "2xx": WhoAmIRes } }, config: { requireAuth: true } }, - async function(req, res) { - return makeResponse("success", "whoami.success", { name: req.authUser?.name }) + async function(req, _res) { + if (isNullish(req.authUser)) + return makeResponse("success", "whoami.failure.generic") + return makeResponse("success", "whoami.success", { name: req.authUser.name }) }, ); }; diff --git a/src/icons/src/routes/set.ts b/src/icons/src/routes/set.ts index 7c8d9f4..7e854c3 100644 --- a/src/icons/src/routes/set.ts +++ b/src/icons/src/routes/set.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { open } from 'node:fs/promises' import sharp from 'sharp' import rawBody from 'raw-body' +import { isNullish } from '@shared/utils' const route: FastifyPluginAsync = async (fastify, opts): Promise => { // await fastify.register(authMethod, {}); @@ -19,7 +20,7 @@ const route: FastifyPluginAsync = async (fastify, opts): Promise => { let buffer = await rawBody(request.raw); // this is how we get the `:userid` part of things const userid: string | undefined = (request.params as any)['userid']; - if (userid === undefined) { + if (isNullish(userid)) { return await reply.code(403); } const image_store: string = fastify.getDecorator('image_store')