Compare commits
	
		
			2 commits
		
	
	
		
			a4a750b35c
			...
			df00324e24
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| df00324e24 | |||
| 3d42591ee5 | 
					 18 changed files with 957 additions and 66 deletions
				
			
		|  | @ -27,7 +27,8 @@ | |||
| ## TODO | ||||
| 
 | ||||
| - [x] sign up | ||||
| - [ ] log in | ||||
| - [x] check for logged in user session | ||||
| - [x] log in | ||||
| - [ ] chat rooms | ||||
| - [ ] chat messages | ||||
| - [ ] bulletin board instead of exchange/work? | ||||
|  |  | |||
|  | @ -7,7 +7,8 @@ | |||
| 	"tasks": { | ||||
| 		"lint": "deno lint", | ||||
| 		"fmt": "deno fmt", | ||||
| 		"serve": "FSDB_ROOT=$PWD/.fsdb deno --allow-env --allow-read --allow-write --allow-net jsr:@andyburke/serverus --root ./public" | ||||
| 		"serve": "FSDB_ROOT=$PWD/.fsdb SERVERUS_TYPESCRIPT_IMPORT_LOGGING=1 deno --allow-env --allow-read --allow-write --allow-net jsr:@andyburke/serverus --root ./public", | ||||
| 		"test": "DENO_ENV=test FSDB_ROOT=$PWD/tests/data/$(date --iso-8601=seconds) SERVERUS_TYPESCRIPT_IMPORT_LOGGING=1 SERVERUS_ROOT=$PWD/public deno test --allow-env --allow-read --allow-write --allow-net --trace-leaks --fail-fast tests/" | ||||
| 	}, | ||||
| 	"test": { | ||||
| 		"exclude": ["tests/data/"] | ||||
|  | @ -33,8 +34,10 @@ | |||
| 	"imports": { | ||||
| 		"@andyburke/fsdb": "jsr:@andyburke/fsdb@^0.4.0", | ||||
| 		"@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0", | ||||
| 		"@andyburke/serverus": "jsr:@andyburke/serverus@^0.0.12", | ||||
| 		"@andyburke/serverus": "jsr:@andyburke/serverus@^0.6.0", | ||||
| 		"@std/assert": "jsr:@std/assert@^1.0.13", | ||||
| 		"@std/encoding": "jsr:@std/encoding@^1.0.10", | ||||
| 		"@std/http": "jsr:@std/http@^1.0.18", | ||||
| 		"@std/path": "jsr:@std/path@^1.1.0", | ||||
| 		"@stdext/crypto": "jsr:@stdext/crypto@^0.1.0" | ||||
| 	} | ||||
|  |  | |||
							
								
								
									
										101
									
								
								deno.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										101
									
								
								deno.lock
									
										
									
										generated
									
									
									
								
							|  | @ -1,21 +1,34 @@ | |||
| { | ||||
|   "version": "5", | ||||
|   "specifiers": { | ||||
|     "jsr:@andyburke/fsdb@*": "0.4.0", | ||||
|     "jsr:@andyburke/fsdb@0.4": "0.4.0", | ||||
|     "jsr:@andyburke/lurid@*": "0.2.0", | ||||
|     "jsr:@andyburke/lurid@0.2": "0.2.0", | ||||
|     "jsr:@andyburke/serverus@^0.0.12": "0.0.12", | ||||
|     "jsr:@andyburke/serverus@0.6": "0.6.0", | ||||
|     "jsr:@std/assert@*": "1.0.13", | ||||
|     "jsr:@std/assert@^1.0.13": "1.0.13", | ||||
|     "jsr:@std/async@^1.0.13": "1.0.13", | ||||
|     "jsr:@std/cli@^1.0.19": "1.0.20", | ||||
|     "jsr:@std/cli@^1.0.20": "1.0.20", | ||||
|     "jsr:@std/encoding@*": "1.0.10", | ||||
|     "jsr:@std/encoding@1": "1.0.10", | ||||
|     "jsr:@std/encoding@^1.0.10": "1.0.10", | ||||
|     "jsr:@std/fmt@^1.0.6": "1.0.8", | ||||
|     "jsr:@std/fmt@^1.0.8": "1.0.8", | ||||
|     "jsr:@std/fs@^1.0.14": "1.0.18", | ||||
|     "jsr:@std/fs@^1.0.18": "1.0.18", | ||||
|     "jsr:@std/http@^1.0.13": "1.0.17", | ||||
|     "jsr:@std/html@^1.0.4": "1.0.4", | ||||
|     "jsr:@std/http@*": "1.0.18", | ||||
|     "jsr:@std/http@^1.0.13": "1.0.18", | ||||
|     "jsr:@std/http@^1.0.18": "1.0.18", | ||||
|     "jsr:@std/internal@^1.0.6": "1.0.8", | ||||
|     "jsr:@std/media-types@^1.1.0": "1.1.0", | ||||
|     "jsr:@std/net@^1.0.4": "1.0.4", | ||||
|     "jsr:@std/path@^1.0.8": "1.1.0", | ||||
|     "jsr:@std/path@^1.1.0": "1.1.0", | ||||
|     "jsr:@std/streams@^1.0.10": "1.0.10", | ||||
|     "jsr:@stdext/crypto@*": "0.1.0", | ||||
|     "jsr:@stdext/crypto@0.1": "0.1.0" | ||||
|   }, | ||||
|   "jsr": { | ||||
|  | @ -33,18 +46,24 @@ | |||
|         "jsr:@std/cli@^1.0.19" | ||||
|       ] | ||||
|     }, | ||||
|     "@andyburke/serverus@0.0.12": { | ||||
|       "integrity": "051cbffd30577e39ca604e009c3870c4b32b7e4118f2da58fc18ec05afa5b5bb", | ||||
|     "@andyburke/serverus@0.6.0": { | ||||
|       "integrity": "89f013c1d77e3d5d2c4e0908b29cc4a1acd19ebf22fa2890a6c5aa777e7b0de3", | ||||
|       "dependencies": [ | ||||
|         "jsr:@std/async", | ||||
|         "jsr:@std/cli@^1.0.19", | ||||
|         "jsr:@std/fmt", | ||||
|         "jsr:@std/fmt@^1.0.6", | ||||
|         "jsr:@std/fs@^1.0.14", | ||||
|         "jsr:@std/http", | ||||
|         "jsr:@std/http@^1.0.13", | ||||
|         "jsr:@std/media-types", | ||||
|         "jsr:@std/path@^1.0.8" | ||||
|       ] | ||||
|     }, | ||||
|     "@std/assert@1.0.13": { | ||||
|       "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", | ||||
|       "dependencies": [ | ||||
|         "jsr:@std/internal" | ||||
|       ] | ||||
|     }, | ||||
|     "@std/async@1.0.13": { | ||||
|       "integrity": "1d76ca5d324aef249908f7f7fe0d39aaf53198e5420604a59ab5c035adc97c96" | ||||
|     }, | ||||
|  | @ -63,15 +82,41 @@ | |||
|         "jsr:@std/path@^1.1.0" | ||||
|       ] | ||||
|     }, | ||||
|     "@std/html@1.0.4": { | ||||
|       "integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e" | ||||
|     }, | ||||
|     "@std/http@1.0.17": { | ||||
|       "integrity": "98aec8ab4080d95c21f731e3008f69c29c5012d12f1b4e553f85935db601569f" | ||||
|     }, | ||||
|     "@std/http@1.0.18": { | ||||
|       "integrity": "8d9546aa532c52a0cf318c74616db0638b4c1073405355d1b14f9e1591dccf20", | ||||
|       "dependencies": [ | ||||
|         "jsr:@std/cli@^1.0.20", | ||||
|         "jsr:@std/encoding@^1.0.10", | ||||
|         "jsr:@std/fmt@^1.0.8", | ||||
|         "jsr:@std/fs@^1.0.18", | ||||
|         "jsr:@std/html", | ||||
|         "jsr:@std/media-types", | ||||
|         "jsr:@std/net", | ||||
|         "jsr:@std/path@^1.1.0", | ||||
|         "jsr:@std/streams" | ||||
|       ] | ||||
|     }, | ||||
|     "@std/internal@1.0.8": { | ||||
|       "integrity": "fc66e846d8d38a47cffd274d80d2ca3f0de71040f855783724bb6b87f60891f5" | ||||
|     }, | ||||
|     "@std/media-types@1.1.0": { | ||||
|       "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" | ||||
|     }, | ||||
|     "@std/net@1.0.4": { | ||||
|       "integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852" | ||||
|     }, | ||||
|     "@std/path@1.1.0": { | ||||
|       "integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886" | ||||
|     }, | ||||
|     "@std/streams@1.0.10": { | ||||
|       "integrity": "75c0b1431873cd0d8b3d679015220204d36d3c7420d93b60acfc379eb0dc30af" | ||||
|     }, | ||||
|     "@stdext/crypto@0.1.0": { | ||||
|       "integrity": "05dc9e754c2529574d8bf98bd40c7dc468a02dcb2fa5e8644fff6813ceab66a4", | ||||
|       "dependencies": [ | ||||
|  | @ -79,12 +124,54 @@ | |||
|       ] | ||||
|     } | ||||
|   }, | ||||
|   "remote": { | ||||
|     "https://deno.land/std@0.167.0/_util/asserts.ts": "d0844e9b62510f89ce1f9878b046f6a57bf88f208a10304aab50efcb48365272", | ||||
|     "https://deno.land/std@0.167.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934", | ||||
|     "https://deno.land/std@0.167.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", | ||||
|     "https://deno.land/std@0.167.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", | ||||
|     "https://deno.land/std@0.167.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677", | ||||
|     "https://deno.land/std@0.167.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", | ||||
|     "https://deno.land/std@0.167.0/path/glob.ts": "81cc6c72be002cd546c7a22d1f263f82f63f37fe0035d9726aa96fc8f6e4afa1", | ||||
|     "https://deno.land/std@0.167.0/path/mod.ts": "cf7cec7ac11b7048bb66af8ae03513e66595c279c65cfa12bfc07d9599608b78", | ||||
|     "https://deno.land/std@0.167.0/path/posix.ts": "b859684bc4d80edfd4cad0a82371b50c716330bed51143d6dcdbe59e6278b30c", | ||||
|     "https://deno.land/std@0.167.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", | ||||
|     "https://deno.land/std@0.167.0/path/win32.ts": "7cebd2bda6657371adc00061a1d23fdd87bcdf64b4843bb148b0b24c11b40f69", | ||||
|     "https://deno.land/std@0.184.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", | ||||
|     "https://deno.land/std@0.184.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", | ||||
|     "https://deno.land/std@0.184.0/async/abortable.ts": "fd682fa46f3b7b16b4606a5ab52a7ce309434b76f820d3221bdfb862719a15d7", | ||||
|     "https://deno.land/std@0.184.0/async/deadline.ts": "c5facb0b404eede83e38bd2717ea8ab34faa2ffb20ef87fd261fcba32ba307aa", | ||||
|     "https://deno.land/std@0.184.0/async/debounce.ts": "adab11d04ca38d699444ac8a9d9856b4155e8dda2afd07ce78276c01ea5a4332", | ||||
|     "https://deno.land/std@0.184.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8", | ||||
|     "https://deno.land/std@0.184.0/async/delay.ts": "73aa04cec034c84fc748c7be49bb15cac3dd43a57174bfdb7a4aec22c248f0dd", | ||||
|     "https://deno.land/std@0.184.0/async/mod.ts": "f04344fa21738e5ad6bea37a6bfffd57c617c2d372bb9f9dcfd118a1b622e576", | ||||
|     "https://deno.land/std@0.184.0/async/mux_async_iterator.ts": "70c7f2ee4e9466161350473ad61cac0b9f115cff4c552eaa7ef9d50c4cbb4cc9", | ||||
|     "https://deno.land/std@0.184.0/async/pool.ts": "fd082bd4aaf26445909889435a5c74334c017847842ec035739b4ae637ae8260", | ||||
|     "https://deno.land/std@0.184.0/async/retry.ts": "dd19d93033d8eaddbfcb7654c0366e9d3b0a21448bdb06eba4a7d8a8cf936a92", | ||||
|     "https://deno.land/std@0.184.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757", | ||||
|     "https://deno.land/std@0.184.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", | ||||
|     "https://deno.land/std@0.184.0/fs/_util.ts": "579038bebc3bd35c43a6a7766f7d91fbacdf44bc03468e9d3134297bb99ed4f9", | ||||
|     "https://deno.land/std@0.184.0/fs/ensure_dir.ts": "dc64c4c75c64721d4e3fb681f1382f803ff3d2868f08563ff923fdd20d071c40", | ||||
|     "https://deno.land/std@0.184.0/fs/walk.ts": "920be35a7376db6c0b5b1caf1486fb962925e38c9825f90367f8f26b5e5d0897", | ||||
|     "https://deno.land/std@0.184.0/http/server.ts": "cbb17b594651215ba95c01a395700684e569c165a567e4e04bba327f41197433", | ||||
|     "https://deno.land/std@0.184.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", | ||||
|     "https://deno.land/std@0.184.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", | ||||
|     "https://deno.land/std@0.184.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", | ||||
|     "https://deno.land/std@0.184.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", | ||||
|     "https://deno.land/std@0.184.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", | ||||
|     "https://deno.land/std@0.184.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", | ||||
|     "https://deno.land/std@0.184.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", | ||||
|     "https://deno.land/std@0.184.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", | ||||
|     "https://deno.land/std@0.184.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", | ||||
|     "https://deno.land/x/ulid@v0.3.0/mod.ts": "f7ff065b66abd485051fc68af23becef6ccc7e81f7774d7fcfd894a4b2da1984" | ||||
|   }, | ||||
|   "workspace": { | ||||
|     "dependencies": [ | ||||
|       "jsr:@andyburke/fsdb@0.4", | ||||
|       "jsr:@andyburke/lurid@0.2", | ||||
|       "jsr:@andyburke/serverus@^0.0.12", | ||||
|       "jsr:@andyburke/serverus@0.6", | ||||
|       "jsr:@std/assert@^1.0.13", | ||||
|       "jsr:@std/encoding@^1.0.10", | ||||
|       "jsr:@std/http@^1.0.18", | ||||
|       "jsr:@std/path@^1.1.0", | ||||
|       "jsr:@stdext/crypto@0.1" | ||||
|     ] | ||||
|  |  | |||
|  | @ -19,6 +19,7 @@ export const SESSIONS = new FSDB_COLLECTION<SESSION>({ | |||
| 		user_id: new FSDB_INDEXER_SYMLINKS<SESSION>({ | ||||
| 			name: 'user_id', | ||||
| 			field: 'user_id', | ||||
| 			to_many: true, | ||||
| 			organize: by_lurid | ||||
| 		}) | ||||
| 	} | ||||
|  |  | |||
|  | @ -9,15 +9,15 @@ import { TOTP_ENTRIES } from '../../../models/totp_entry.ts'; | |||
| import { verifyTotp } from 'jsr:@stdext/crypto/totp'; | ||||
| import { encodeBase64 } from 'jsr:@std/encoding/base64'; | ||||
| import parse_body from '../../../utils/bodyparser.ts'; | ||||
| import { SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts'; | ||||
| 
 | ||||
| const DEFAULT_SESSION_TIME: number = 60 * 60; // 1 Hour
 | ||||
| const DEFAULT_SESSION_TIME: number = 60 * 60 * 1_000; // 1 Hour
 | ||||
| 
 | ||||
| // POST /api/auth - Authenticate
 | ||||
| export async function POST(req: Request, meta: Record<string, any>): Promise<Response> { | ||||
| 	try { | ||||
| 		const body = await parse_body(req); | ||||
| 
 | ||||
| 		const email: string = body.email?.toLowerCase().trim() ?? ''; | ||||
| 		const username: string = body.username?.toLowerCase() ?? ''; | ||||
| 		const password: string | undefined = body.password; | ||||
| 		const password_hash: string | undefined = body.password_hash ?? (typeof password === 'string' | ||||
|  | @ -27,11 +27,11 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res | |||
| 			: undefined); | ||||
| 		const totp: string | undefined = body.totp; | ||||
| 
 | ||||
| 		if ((!email.length && !username.length) || (email.length && username.length)) { | ||||
| 		if (!username.length) { | ||||
| 			return Response.json({ | ||||
| 				error: { | ||||
| 					message: 'You must specify either an email or username to log in.', | ||||
| 					cause: 'email_or_username_required' | ||||
| 					message: 'You must specify a username to log in.', | ||||
| 					cause: 'username_required' | ||||
| 				} | ||||
| 			}, { | ||||
| 				status: 400 | ||||
|  | @ -51,20 +51,14 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res | |||
| 		} | ||||
| 
 | ||||
| 		let user: USER | undefined = undefined; | ||||
| 		if (email.length) { | ||||
| 			user = (await USERS.find({ | ||||
| 				email | ||||
| 			})).shift(); | ||||
| 		} else if (username.length) { | ||||
| 			user = (await USERS.find({ | ||||
| 				username | ||||
| 			})).shift(); | ||||
| 		} | ||||
| 		user = (await USERS.find({ | ||||
| 			username | ||||
| 		})).shift(); | ||||
| 
 | ||||
| 		if (!user) { | ||||
| 			return Response.json({ | ||||
| 				error: { | ||||
| 					message: 'Could not locate an account with this email or username.', | ||||
| 					message: `Could not locate an account with username: ${username}`, | ||||
| 					cause: 'missing_account' | ||||
| 				} | ||||
| 			}, { | ||||
|  | @ -105,7 +99,7 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res | |||
| 				}, { | ||||
| 					status: 202, | ||||
| 					headers: { | ||||
| 						'Set-Cookie': `checklist_observer_totp_user_id=${user.id}; Path=/; Max-Age=300` | ||||
| 						'Set-Cookie': `totp_user_id=${user.id}; Path=/; Max-Age=300` | ||||
| 					} | ||||
| 				}); | ||||
| 			} | ||||
|  | @ -123,7 +117,7 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res | |||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		const session_result: SESSION_RESULT = await get_session({ | ||||
| 		const session_result: SESSION_RESULT = await create_new_session({ | ||||
| 			user, | ||||
| 			expires: body.session?.expires | ||||
| 		}); | ||||
|  | @ -164,7 +158,7 @@ export type SESSION_INFO = { | |||
| 	expires: string | undefined; | ||||
| }; | ||||
| 
 | ||||
| export async function get_session(session_settings: SESSION_INFO): Promise<SESSION_RESULT> { | ||||
| export async function create_new_session(session_settings: SESSION_INFO): Promise<SESSION_RESULT> { | ||||
| 	const now = new Date().toISOString(); | ||||
| 	const expires: string = session_settings.expires ?? | ||||
| 		new Date(new Date(now).valueOf() + DEFAULT_SESSION_TIME).toISOString(); | ||||
|  | @ -183,7 +177,13 @@ export async function get_session(session_settings: SESSION_INFO): Promise<SESSI | |||
| 	await SESSIONS.create(session); | ||||
| 
 | ||||
| 	const headers = new Headers(); | ||||
| 	headers.append('Set-Cookie', `checklist_observer_session_id=${session.id}; Path=/; Expires=${expires}`); | ||||
| 
 | ||||
| 	headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=${session.id}; Path=/; Expires=${expires}`); | ||||
| 	headers.append(`x-${SESSION_ID_TOKEN}`, session.id); | ||||
| 
 | ||||
| 	// TODO: this wasn't really intended to be persisted in a cookie, but we are using it to
 | ||||
| 	// generate the TOTP for the call to /api/users/me
 | ||||
| 	headers.append('Set-Cookie', `${SESSION_SECRET_TOKEN}=${session.secret}; Path=/; Expires=${expires}`); | ||||
| 
 | ||||
| 	return { | ||||
| 		session, | ||||
|  |  | |||
|  | @ -1,19 +1,24 @@ | |||
| import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../utils/prechecks.ts'; | ||||
| import { PASSWORD_ENTRIES, PASSWORD_ENTRY } from '../../../../models/password_entry.ts'; | ||||
| import { SESSIONS } from '../../../../models/session.ts'; | ||||
| import { USER, USERS } from '../../../../models/user.ts'; | ||||
| import { PERMISSIONS_STORE, USER_PERMISSIONS } from '../../../../models/user_permissions.ts'; | ||||
| import parse_body from '../../../../utils/bodyparser.ts'; | ||||
| import { CANNED_RESPONSES } from '../../../../utils/canned_responses.ts'; | ||||
| 
 | ||||
| export const PERMISSIONS: Record<string, (req: Request, meta: Record<string, any>) => Promise<boolean>> = {}; | ||||
| export const PRECHECKS: PRECHECK_TABLE = {}; | ||||
| 
 | ||||
| // GET /api/users/:id - Get single user
 | ||||
| PERMISSIONS.GET = (_req: Request, meta: Record<string, any>): Promise<boolean> => { | ||||
| PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => { | ||||
| 	const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id; | ||||
| 	const can_read_self = meta.user_permissions?.permissions.includes('self.read'); | ||||
| 	const can_read_others = meta.user_permissions?.permissions?.includes('users.read'); | ||||
| 
 | ||||
| 	return can_read_others || (can_read_self && user_is_self); | ||||
| }; | ||||
| 	const has_permission = can_read_others || (can_read_self && user_is_self); | ||||
| 	if (!has_permission) { | ||||
| 		return CANNED_RESPONSES.permission_denied(); | ||||
| 	} | ||||
| }]; | ||||
| export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> { | ||||
| 	const user_id: string = meta.params?.id?.toLowerCase().trim() ?? ''; | ||||
| 	const user: USER | null = user_id.length === 49 ? await USERS.get(user_id) : null; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
 | ||||
|  | @ -29,34 +34,22 @@ export async function GET(_req: Request, meta: Record<string, any>): Promise<Res | |||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	const user_is_self = meta.user?.id === user.id; | ||||
| 	const has_permission_to_read = (user_is_self && meta.user_permissions?.permissions?.includes('self.read')) || | ||||
| 		(meta.user_permissions?.permissions?.includes('users.read')); | ||||
| 
 | ||||
| 	if (!has_permission_to_read) { | ||||
| 		return Response.json({ | ||||
| 			error: { | ||||
| 				message: 'Permission denied.', | ||||
| 				cause: 'permission_denied' | ||||
| 			} | ||||
| 		}, { | ||||
| 			status: 400 | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	return Response.json(user, { | ||||
| 		status: 200 | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| // PUT /api/users/:id - Update user
 | ||||
| PERMISSIONS.PUT = (_req: Request, meta: Record<string, any>): Promise<boolean> => { | ||||
| PRECHECKS.PUT = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => { | ||||
| 	const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id; | ||||
| 	const can_write_self = meta.user_permissions?.permissions.includes('self.write'); | ||||
| 	const can_write_others = meta.user_permissions?.permissions?.includes('users.write'); | ||||
| 
 | ||||
| 	return can_write_others || (can_write_self && user_is_self); | ||||
| }; | ||||
| 	const has_permission = can_write_others || (can_write_self && user_is_self); | ||||
| 	if (!has_permission) { | ||||
| 		return CANNED_RESPONSES.permission_denied(); | ||||
| 	} | ||||
| }]; | ||||
| export async function PUT(req: Request, meta: { params: Record<string, any> }): Promise<Response> { | ||||
| 	const now = new Date().toISOString(); | ||||
| 	const id: string = meta.params.id ?? ''; | ||||
|  | @ -101,13 +94,16 @@ export async function PUT(req: Request, meta: { params: Record<string, any> }): | |||
| } | ||||
| 
 | ||||
| // DELETE /api/users/:id - Delete user
 | ||||
| PERMISSIONS.DELETE = (_req: Request, meta: Record<string, any>): Promise<boolean> => { | ||||
| PRECHECKS.DELETE = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => { | ||||
| 	const user_is_self = !!meta.user && !!meta.params && meta.user.id === meta.params.id; | ||||
| 	const can_write_self = meta.user_permissions?.permissions.includes('self.write'); | ||||
| 	const can_write_others = meta.user_permissions?.permissions?.includes('users.write'); | ||||
| 
 | ||||
| 	return can_write_others || (can_write_self && user_is_self); | ||||
| }; | ||||
| 	const has_permission = can_write_others || (can_write_self && user_is_self); | ||||
| 	if (!has_permission) { | ||||
| 		return CANNED_RESPONSES.permission_denied(); | ||||
| 	} | ||||
| }]; | ||||
| export async function DELETE(_req: Request, meta: { params: Record<string, any> }): Promise<Response> { | ||||
| 	const user_id: string = meta.params.id ?? ''; | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,28 +6,27 @@ import { hash } from 'jsr:@stdext/crypto/hash'; | |||
| import lurid from 'jsr:@andyburke/lurid'; | ||||
| import { encodeBase64 } from 'jsr:@std/encoding'; | ||||
| import parse_body from '../../../utils/bodyparser.ts'; | ||||
| import { get_session, SESSION_RESULT } from '../auth/index.ts'; | ||||
| import { create_new_session, SESSION_RESULT } from '../auth/index.ts'; | ||||
| import { PRECHECKS } from './me/index.ts'; | ||||
| import { get_session, get_user, require_user } from '../../../utils/prechecks.ts'; | ||||
| import { CANNED_RESPONSES } from '../../../utils/canned_responses.ts'; | ||||
| 
 | ||||
| // TODO: figure out a better solution for doling out permissions
 | ||||
| const DEFAULT_USER_PERMISSIONS: string[] = [ | ||||
| 	'self.read', | ||||
| 	'self.write', | ||||
| 	'checklists.read', | ||||
| 	'checklists.write', | ||||
| 	'checklists.events.read', | ||||
| 	'checklists.events.write' | ||||
| 	'self.write' | ||||
| ]; | ||||
| 
 | ||||
| export const PERMISSIONS: Record<string, (req: Request, meta: Record<string, any>) => Promise<boolean>> = {}; | ||||
| 
 | ||||
| // GET /api/users - get users
 | ||||
| // query parameters:
 | ||||
| //   partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted)
 | ||||
| PERMISSIONS.GET = (_req: Request, meta: Record<string, any>): Promise<boolean> => { | ||||
| PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => { | ||||
| 	const can_read_others = meta.user_permissions?.permissions?.includes('users.read'); | ||||
| 
 | ||||
| 	return can_read_others; | ||||
| }; | ||||
| 	if (!can_read_others) { | ||||
| 		return CANNED_RESPONSES.permission_denied(); | ||||
| 	} | ||||
| }]; | ||||
| export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> { | ||||
| 	const query: URLSearchParams = meta.query; | ||||
| 	const partial_id: string | undefined = query.get('partial_id')?.toLowerCase().trim(); | ||||
|  | @ -132,7 +131,7 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res | |||
| 
 | ||||
| 		await PERMISSIONS_STORE.create(user_permissions); | ||||
| 
 | ||||
| 		const session_result: SESSION_RESULT = await get_session({ | ||||
| 		const session_result: SESSION_RESULT = await create_new_session({ | ||||
| 			user, | ||||
| 			expires: undefined | ||||
| 		}); | ||||
|  |  | |||
							
								
								
									
										7
									
								
								public/api/users/me/README.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								public/api/users/me/README.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| # /api/users/me | ||||
| 
 | ||||
| Get the current user. | ||||
| 
 | ||||
| ## GET /api/users/me | ||||
| 
 | ||||
| Return the currently logged in user or an error. | ||||
							
								
								
									
										25
									
								
								public/api/users/me/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								public/api/users/me/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| import { CANNED_RESPONSES } from '../../../../utils/canned_responses.ts'; | ||||
| import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../utils/prechecks.ts'; | ||||
| 
 | ||||
| export const PERMISSIONS: Record<string, (req: Request, meta: Record<string, any>) => Promise<boolean>> = {}; | ||||
| export const PRECHECKS: PRECHECK_TABLE = {}; | ||||
| 
 | ||||
| // GET /api/users/me - Get the current user
 | ||||
| PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => { | ||||
| 	const can_read_self = meta.user_permissions?.permissions.includes('self.read'); | ||||
| 
 | ||||
| 	const has_permission = can_read_self; | ||||
| 	console.dir({ | ||||
| 		meta, | ||||
| 		can_read_self, | ||||
| 		has_permission | ||||
| 	}); | ||||
| 	if (!has_permission) { | ||||
| 		return CANNED_RESPONSES.permission_denied(); | ||||
| 	} | ||||
| }]; | ||||
| export function GET(_req: Request, meta: Record<string, any>): Response { | ||||
| 	return Response.json(meta.user, { | ||||
| 		status: 200 | ||||
| 	}); | ||||
| } | ||||
|  | @ -468,6 +468,54 @@ | |||
| 				top: 8px; | ||||
| 			} | ||||
| 		</style> | ||||
| 
 | ||||
| 		<script> | ||||
| 			/* https://github.com/turistu/totp-in-javascript/blob/main/totp.js */ | ||||
| 
 | ||||
| 			async function otp_totp(key, secs = 30, digits = 6) { | ||||
| 				return otp_hotp(otp_unbase32(key), otp_pack64bu(Date.now() / 1000 / secs), digits); | ||||
| 			} | ||||
| 			async function otp_hotp(key, counter, digits) { | ||||
| 				let y = self.crypto.subtle; | ||||
| 				if (!y) throw Error("no self.crypto.subtle object available"); | ||||
| 				let k = await y.importKey("raw", key, { name: "HMAC", hash: "SHA-1" }, false, [ | ||||
| 					"sign", | ||||
| 				]); | ||||
| 				return otp_hotp_truncate(await y.sign("HMAC", k, counter), digits); | ||||
| 			} | ||||
| 			function otp_hotp_truncate(buf, digits) { | ||||
| 				let a = new Uint8Array(buf), | ||||
| 					i = a[19] & 0xf; | ||||
| 				return otp_fmt( | ||||
| 					10, | ||||
| 					digits, | ||||
| 					(((a[i] & 0x7f) << 24) | (a[i + 1] << 16) | (a[i + 2] << 8) | a[i + 3]) % | ||||
| 						10 ** digits, | ||||
| 				); | ||||
| 			} | ||||
| 
 | ||||
| 			function otp_fmt(base, width, num) { | ||||
| 				return num.toString(base).padStart(width, "0"); | ||||
| 			} | ||||
| 			function otp_unbase32(s) { | ||||
| 				let t = (s.toLowerCase().match(/\S/g) || []) | ||||
| 					.map((c) => { | ||||
| 						let i = "abcdefghijklmnopqrstuvwxyz234567".indexOf(c); | ||||
| 						if (i < 0) throw Error(`bad char '${c}' in key`); | ||||
| 						return otp_fmt(2, 5, i); | ||||
| 					}) | ||||
| 					.join(""); | ||||
| 				if (t.length < 8) throw Error("key too short"); | ||||
| 				return new Uint8Array(t.match(/.{8}/g).map((d) => parseInt(d, 2))); | ||||
| 			} | ||||
| 			function otp_pack64bu(v) { | ||||
| 				let b = new ArrayBuffer(8), | ||||
| 					d = new DataView(b); | ||||
| 				d.setUint32(0, v / 2 ** 32); | ||||
| 				d.setUint32(4, v); | ||||
| 				return b; | ||||
| 			} | ||||
| 		</script> | ||||
| 	</head> | ||||
| 	<body> | ||||
| 		<div id="signup-login-wall"> | ||||
|  | @ -578,6 +626,13 @@ | |||
| 								action="/api/auth" | ||||
| 								onreply="(user)=>{ document.body.dataset.user = user; }" | ||||
| 							> | ||||
| 								<script> | ||||
| 									const form = document.currentScript.closest("form"); | ||||
| 									form.on_response = (response) => { | ||||
| 										// TODO: we should hold the session secret only in memory, not the cookie? | ||||
| 										document.body.dataset.user = response.user; | ||||
| 									}; | ||||
| 								</script> | ||||
| 								<div> | ||||
| 									<input | ||||
| 										id="login-username" | ||||
|  | @ -763,6 +818,50 @@ | |||
| 	</body> | ||||
| 	<script> | ||||
| 		document.addEventListener("DOMContentLoaded", () => { | ||||
| 			/* check if we are logged in */ | ||||
| 			(async () => { | ||||
| 				try { | ||||
| 					const session_id = (document.cookie.match( | ||||
| 						/^(?:.*;)?\s*session_id\s*=\s*([^;]+)(?:.*)?$/, | ||||
| 					) || [, null])[1]; | ||||
| 
 | ||||
| 					// TODO: this wasn't really intended to be persisted in a cookie | ||||
| 					const session_secret = (document.cookie.match( | ||||
| 						/^(?:.*;)?\s*session_secret\s*=\s*([^;]+)(?:.*)?$/, | ||||
| 					) || [, null])[1]; | ||||
| 
 | ||||
| 					if (session_id && session_secret) { | ||||
| 						const session_response = await fetch("/api/users/me", { | ||||
| 							method: "GET", | ||||
| 							headers: { | ||||
| 								Accept: "application/json", | ||||
| 								"x-session_id": session_id, | ||||
| 								"x-totp": await otp_totp(session_secret), | ||||
| 							}, | ||||
| 						}); | ||||
| 
 | ||||
| 						if (!session_response.ok) { | ||||
| 							const error_body = await session_response.json(); | ||||
| 							const error = error_body?.error; | ||||
| 
 | ||||
| 							console.dir({ | ||||
| 								error_body, | ||||
| 								error, | ||||
| 							}); | ||||
| 							return; | ||||
| 						} | ||||
| 
 | ||||
| 						const user = await session_response.json(); | ||||
| 						document.body.dataset.user = user; | ||||
| 					} | ||||
| 				} catch (error) { | ||||
| 					console.dir({ | ||||
| 						error, | ||||
| 					}); | ||||
| 				} | ||||
| 			})(); | ||||
| 
 | ||||
| 			/* make all forms semi-smart */ | ||||
| 			const forms = document.querySelectorAll("form"); | ||||
| 			for (const form of forms) { | ||||
| 				const script = form.querySelector("script"); | ||||
|  |  | |||
							
								
								
									
										77
									
								
								tests/api/users/create_user.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								tests/api/users/create_user.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,77 @@ | |||
| import { api, API_CLIENT } from '../../../utils/api.ts'; | ||||
| import * as asserts from 'jsr:@std/assert'; | ||||
| import { USER } from '../../../models/user.ts'; | ||||
| import { EPHEMERAL_SERVER, get_ephemeral_listen_server, random_username } from '../../helpers.ts'; | ||||
| import { Cookie, getSetCookies } from '@std/http/cookie'; | ||||
| import { encodeBase64 } from '@std/encoding'; | ||||
| import { generateTotp } from '@stdext/crypto/totp'; | ||||
| 
 | ||||
| Deno.test({ | ||||
| 	name: 'API - USERS - Create', | ||||
| 	permissions: { | ||||
| 		env: true, | ||||
| 		read: true, | ||||
| 		write: true, | ||||
| 		net: true | ||||
| 	}, | ||||
| 	fn: async () => { | ||||
| 		let test_server_info: EPHEMERAL_SERVER | null = null; | ||||
| 		try { | ||||
| 			test_server_info = await get_ephemeral_listen_server(); | ||||
| 			const client: API_CLIENT = api({ | ||||
| 				prefix: '/api', | ||||
| 				hostname: test_server_info.hostname, | ||||
| 				port: test_server_info.port | ||||
| 			}); | ||||
| 
 | ||||
| 			const username = random_username(); | ||||
| 			const password = 'password'; | ||||
| 			const password_hash = encodeBase64( | ||||
| 				await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)) | ||||
| 			); | ||||
| 
 | ||||
| 			let cookies: Cookie[] = []; | ||||
| 
 | ||||
| 			const user_creation_response: Record<string, any> = await client.fetch('/users', { | ||||
| 				method: 'POST', | ||||
| 				json: { | ||||
| 					username, | ||||
| 					password_hash | ||||
| 				}, | ||||
| 				done: (response) => { | ||||
| 					cookies = getSetCookies(response.headers); | ||||
| 				} | ||||
| 			}); | ||||
| 
 | ||||
| 			asserts.assert(user_creation_response?.user); | ||||
| 			asserts.assert(user_creation_response?.session); | ||||
| 
 | ||||
| 			const user: USER | undefined = user_creation_response.user; | ||||
| 			const session: Record<string, any> | undefined = user_creation_response.session; | ||||
| 
 | ||||
| 			cookies.push({ | ||||
| 				name: 'totp', | ||||
| 				value: await generateTotp(session?.secret), | ||||
| 				maxAge: 30, | ||||
| 				expires: Date.now() + 30_000, | ||||
| 				path: '/' | ||||
| 			}); | ||||
| 
 | ||||
| 			const headers_for_get = new Headers(); | ||||
| 			for (const cookie of cookies) { | ||||
| 				headers_for_get.append(`x-${cookie.name}`, cookie.value); | ||||
| 			} | ||||
| 			headers_for_get.append('cookie', cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')); | ||||
| 
 | ||||
| 			const retrieved_user: USER = await client.fetch(`/users/${user?.id}`, { | ||||
| 				headers: headers_for_get | ||||
| 			}) as USER; | ||||
| 
 | ||||
| 			asserts.assertObjectMatch(retrieved_user, user ?? {}); | ||||
| 		} finally { | ||||
| 			if (test_server_info) { | ||||
| 				await test_server_info?.server?.stop(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
							
								
								
									
										84
									
								
								tests/api/users/delete_user.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								tests/api/users/delete_user.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,84 @@ | |||
| import { api, API_CLIENT } from '../../../utils/api.ts'; | ||||
| import * as asserts from '@std/assert'; | ||||
| import { USER } from '../../../models/user.ts'; | ||||
| import { EPHEMERAL_SERVER, get_ephemeral_listen_server, random_username } from '../../helpers.ts'; | ||||
| import { Cookie, getSetCookies } from '@std/http/cookie'; | ||||
| import { encodeBase64 } from '@std/encoding'; | ||||
| import { generateTotp } from '@stdext/crypto/totp'; | ||||
| 
 | ||||
| Deno.test({ | ||||
| 	name: 'API - USERS - Delete', | ||||
| 	permissions: { | ||||
| 		env: true, | ||||
| 		read: true, | ||||
| 		write: true, | ||||
| 		net: true | ||||
| 	}, | ||||
| 	fn: async () => { | ||||
| 		let test_server_info: EPHEMERAL_SERVER | null = null; | ||||
| 		try { | ||||
| 			test_server_info = await get_ephemeral_listen_server(); | ||||
| 			const client: API_CLIENT = api({ | ||||
| 				prefix: '/api', | ||||
| 				hostname: test_server_info.hostname, | ||||
| 				port: test_server_info.port | ||||
| 			}); | ||||
| 
 | ||||
| 			const username = random_username(); | ||||
| 			const password = 'password'; | ||||
| 			const password_hash = encodeBase64( | ||||
| 				await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)) | ||||
| 			); | ||||
| 
 | ||||
| 			let cookies: Cookie[] = []; | ||||
| 
 | ||||
| 			const user_creation_response: Record<string, any> = await client.fetch('/users', { | ||||
| 				method: 'POST', | ||||
| 				json: { | ||||
| 					username, | ||||
| 					password_hash | ||||
| 				}, | ||||
| 				done: (response) => { | ||||
| 					cookies = getSetCookies(response.headers); | ||||
| 				} | ||||
| 			}); | ||||
| 
 | ||||
| 			asserts.assert(user_creation_response?.user); | ||||
| 			asserts.assert(user_creation_response?.session); | ||||
| 
 | ||||
| 			const user: USER | undefined = user_creation_response.user; | ||||
| 			const session: Record<string, any> | undefined = user_creation_response.session; | ||||
| 
 | ||||
| 			cookies.push({ | ||||
| 				name: 'totp', | ||||
| 				value: await generateTotp(session?.secret), | ||||
| 				maxAge: 30, | ||||
| 				expires: Date.now() + 30_000, | ||||
| 				path: '/' | ||||
| 			}); | ||||
| 
 | ||||
| 			const headers_for_get = new Headers(); | ||||
| 			for (const cookie of cookies) { | ||||
| 				headers_for_get.append(`x-${cookie.name}`, cookie.value); | ||||
| 			} | ||||
| 			headers_for_get.append('cookie', cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')); | ||||
| 
 | ||||
| 			const retrieved_user: USER = await client.fetch(`/users/${user?.id}`, { | ||||
| 				headers: headers_for_get | ||||
| 			}) as USER; | ||||
| 
 | ||||
| 			asserts.assertObjectMatch(retrieved_user, user ?? {}); | ||||
| 
 | ||||
| 			const deleted_user_response: Record<string, any> = await client.fetch(`/users/${user?.id}`, { | ||||
| 				method: 'DELETE', | ||||
| 				headers: headers_for_get | ||||
| 			}) as USER; | ||||
| 
 | ||||
| 			asserts.assert(deleted_user_response?.deleted); | ||||
| 		} finally { | ||||
| 			if (test_server_info) { | ||||
| 				await test_server_info?.server?.stop(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
							
								
								
									
										157
									
								
								tests/api/users/login.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								tests/api/users/login.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,157 @@ | |||
| import { api, API_CLIENT } from '../../../utils/api.ts'; | ||||
| import * as asserts from '@std/assert'; | ||||
| import { USER } from '../../../models/user.ts'; | ||||
| import { EPHEMERAL_SERVER, get_ephemeral_listen_server, random_username } from '../../helpers.ts'; | ||||
| import { Cookie, getSetCookies } from '@std/http/cookie'; | ||||
| import { encodeBase64 } from '@std/encoding'; | ||||
| import { generateTotp } from '@stdext/crypto/totp'; | ||||
| 
 | ||||
| Deno.test({ | ||||
| 	name: 'API - USERS - Login (password)', | ||||
| 	permissions: { | ||||
| 		env: true, | ||||
| 		read: true, | ||||
| 		write: true, | ||||
| 		net: true | ||||
| 	}, | ||||
| 	fn: async () => { | ||||
| 		let test_server_info: EPHEMERAL_SERVER | null = null; | ||||
| 		try { | ||||
| 			test_server_info = await get_ephemeral_listen_server(); | ||||
| 			const client: API_CLIENT = api({ | ||||
| 				prefix: '/api', | ||||
| 				hostname: test_server_info.hostname, | ||||
| 				port: test_server_info.port | ||||
| 			}); | ||||
| 
 | ||||
| 			const username = random_username(); | ||||
| 			const password = 'password'; | ||||
| 
 | ||||
| 			const user_creation_response: Record<string, any> = await client.fetch('/users', { | ||||
| 				method: 'POST', | ||||
| 				json: { | ||||
| 					username, | ||||
| 					password | ||||
| 				} | ||||
| 			}); | ||||
| 
 | ||||
| 			asserts.assert(user_creation_response?.user); | ||||
| 			asserts.assert(user_creation_response?.session); | ||||
| 
 | ||||
| 			let cookies: Cookie[] = []; | ||||
| 			const auth_response: any = await client.fetch('/auth', { | ||||
| 				method: 'POST', | ||||
| 				json: { | ||||
| 					username, | ||||
| 					password: 'password' | ||||
| 				}, | ||||
| 				done: (response) => { | ||||
| 					cookies = getSetCookies(response.headers); | ||||
| 				} | ||||
| 			}); | ||||
| 
 | ||||
| 			const user: USER | undefined = auth_response.user; | ||||
| 			const session: Record<string, any> | undefined = auth_response.session; | ||||
| 
 | ||||
| 			cookies.push({ | ||||
| 				name: 'totp', | ||||
| 				value: await generateTotp(session?.secret ?? ''), | ||||
| 				maxAge: 30, | ||||
| 				expires: Date.now() + 30_000, | ||||
| 				path: '/' | ||||
| 			}); | ||||
| 
 | ||||
| 			const headers_for_get = new Headers(); | ||||
| 			for (const cookie of cookies) { | ||||
| 				headers_for_get.append(`x-${cookie.name}`, cookie.value); | ||||
| 			} | ||||
| 			headers_for_get.append( | ||||
| 				'cookie', | ||||
| 				cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ') | ||||
| 			); | ||||
| 
 | ||||
| 			const retrieved_user: USER = await client.fetch(`/users/${user?.id}`, { | ||||
| 				headers: headers_for_get | ||||
| 			}) as USER; | ||||
| 
 | ||||
| 			asserts.assertObjectMatch(retrieved_user, user ?? {}); | ||||
| 		} finally { | ||||
| 			if (test_server_info) { | ||||
| 				await test_server_info?.server?.stop(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
| 
 | ||||
| Deno.test({ | ||||
| 	name: 'API - USERS - Login (password_hash)', | ||||
| 	permissions: { | ||||
| 		env: true, | ||||
| 		read: true, | ||||
| 		write: true, | ||||
| 		net: true | ||||
| 	}, | ||||
| 	fn: async () => { | ||||
| 		let test_server_info: EPHEMERAL_SERVER | null = null; | ||||
| 		try { | ||||
| 			test_server_info = await get_ephemeral_listen_server(); | ||||
| 			const client: API_CLIENT = api({ | ||||
| 				prefix: '/api', | ||||
| 				hostname: test_server_info.hostname, | ||||
| 				port: test_server_info.port | ||||
| 			}); | ||||
| 
 | ||||
| 			const username = random_username(); | ||||
| 			const password = 'hashed password!!!'; | ||||
| 			const password_hash = encodeBase64( | ||||
| 				await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)) | ||||
| 			); | ||||
| 
 | ||||
| 			let cookies: Cookie[] = []; | ||||
| 
 | ||||
| 			const user_creation_response: Record<string, any> = await client.fetch('/users', { | ||||
| 				method: 'POST', | ||||
| 				json: { | ||||
| 					username, | ||||
| 					password_hash | ||||
| 				}, | ||||
| 				done: (response) => { | ||||
| 					cookies = getSetCookies(response.headers); | ||||
| 				} | ||||
| 			}); | ||||
| 
 | ||||
| 			asserts.assert(user_creation_response?.user); | ||||
| 			asserts.assert(user_creation_response?.session); | ||||
| 
 | ||||
| 			const user: USER | undefined = user_creation_response.user; | ||||
| 			const session: Record<string, any> | undefined = user_creation_response.session; | ||||
| 
 | ||||
| 			cookies.push({ | ||||
| 				name: 'totp', | ||||
| 				value: await generateTotp(session?.secret), | ||||
| 				maxAge: 30, | ||||
| 				expires: Date.now() + 30_000, | ||||
| 				path: '/' | ||||
| 			}); | ||||
| 
 | ||||
| 			const headers_for_get = new Headers(); | ||||
| 			for (const cookie of cookies) { | ||||
| 				headers_for_get.append(`x-${cookie.name}`, cookie.value); | ||||
| 			} | ||||
| 			headers_for_get.append( | ||||
| 				'cookie', | ||||
| 				cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ') | ||||
| 			); | ||||
| 
 | ||||
| 			const retrieved_user: USER = await client.fetch(`/users/${user?.id}`, { | ||||
| 				headers: headers_for_get | ||||
| 			}) as USER; | ||||
| 
 | ||||
| 			asserts.assertObjectMatch(retrieved_user, user ?? {}); | ||||
| 		} finally { | ||||
| 			if (test_server_info) { | ||||
| 				await test_server_info?.server?.stop(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
							
								
								
									
										82
									
								
								tests/api/users/update_user.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								tests/api/users/update_user.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,82 @@ | |||
| import { api, API_CLIENT } from '../../../utils/api.ts'; | ||||
| import * as asserts from '@std/assert'; | ||||
| import { USER } from '../../../models/user.ts'; | ||||
| import { EPHEMERAL_SERVER, get_ephemeral_listen_server, random_username } from '../../helpers.ts'; | ||||
| import { Cookie, getSetCookies } from '@std/http/cookie'; | ||||
| import { encodeBase64 } from '@std/encoding'; | ||||
| import { generateTotp } from '@stdext/crypto/totp'; | ||||
| 
 | ||||
| Deno.test({ | ||||
| 	name: 'API - USERS - Update', | ||||
| 	permissions: { | ||||
| 		env: true, | ||||
| 		read: true, | ||||
| 		write: true, | ||||
| 		net: true | ||||
| 	}, | ||||
| 	fn: async () => { | ||||
| 		let test_server_info: EPHEMERAL_SERVER | null = null; | ||||
| 		try { | ||||
| 			test_server_info = await get_ephemeral_listen_server(); | ||||
| 			const client: API_CLIENT = api({ | ||||
| 				prefix: '/api', | ||||
| 				hostname: test_server_info.hostname, | ||||
| 				port: test_server_info.port | ||||
| 			}); | ||||
| 
 | ||||
| 			const username = random_username(); | ||||
| 			const password = 'password'; | ||||
| 			const password_hash = encodeBase64( | ||||
| 				await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)) | ||||
| 			); | ||||
| 
 | ||||
| 			let cookies: Cookie[] = []; | ||||
| 
 | ||||
| 			const user_creation_response: Record<string, any> = await client.fetch('/users', { | ||||
| 				method: 'POST', | ||||
| 				json: { | ||||
| 					username, | ||||
| 					password_hash | ||||
| 				}, | ||||
| 				done: (response) => { | ||||
| 					cookies = getSetCookies(response.headers); | ||||
| 				} | ||||
| 			}); | ||||
| 
 | ||||
| 			asserts.assert(user_creation_response?.user); | ||||
| 			asserts.assert(user_creation_response?.session); | ||||
| 
 | ||||
| 			const user: USER | undefined = user_creation_response.user; | ||||
| 			const session: Record<string, any> | undefined = user_creation_response.session; | ||||
| 
 | ||||
| 			cookies.push({ | ||||
| 				name: 'totp', | ||||
| 				value: await generateTotp(session?.secret), | ||||
| 				maxAge: 30, | ||||
| 				expires: Date.now() + 30_000, | ||||
| 				path: '/' | ||||
| 			}); | ||||
| 
 | ||||
| 			const headers_for_put = new Headers(); | ||||
| 			for (const cookie of cookies) { | ||||
| 				headers_for_put.append(`x-${cookie.name}`, cookie.value); | ||||
| 			} | ||||
| 			headers_for_put.append('cookie', cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')); | ||||
| 
 | ||||
| 			const updated_user: USER = await client.fetch(`/users/${user?.id}`, { | ||||
| 				method: 'PUT', | ||||
| 				json: { | ||||
| 					username: random_username() | ||||
| 				}, | ||||
| 				headers: headers_for_put | ||||
| 			}) as USER; | ||||
| 
 | ||||
| 			asserts.assertNotEquals(user?.username ?? '', updated_user?.username ?? ''); | ||||
| 			asserts.assertNotEquals(user?.timestamps.updated ?? '', updated_user?.timestamps.updated ?? ''); | ||||
| 		} finally { | ||||
| 			if (test_server_info) { | ||||
| 				await test_server_info?.server?.stop(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
							
								
								
									
										96
									
								
								tests/helpers.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								tests/helpers.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,96 @@ | |||
| import { SERVER, SERVER_OPTIONS } from 'jsr:@andyburke/serverus/server'; | ||||
| import { convert_to_words } from 'jsr:@andyburke/lurid/word_bytes'; | ||||
| 
 | ||||
| const TLDs: string[] = [ | ||||
| 	'com', | ||||
| 	'org', | ||||
| 	'net', | ||||
| 	'edu', | ||||
| 	'gov', | ||||
| 	'nexus', | ||||
| 	'shop', | ||||
| 	'unreasonablylongtldname' | ||||
| ]; | ||||
| 
 | ||||
| const random_byte_buffer: Uint8Array = new Uint8Array(3); | ||||
| export function random_email_address(): string { | ||||
| 	crypto.getRandomValues(random_byte_buffer); | ||||
| 	const name = convert_to_words(random_byte_buffer).join('-'); | ||||
| 
 | ||||
| 	crypto.getRandomValues(random_byte_buffer); | ||||
| 	const domain = convert_to_words(random_byte_buffer).join('-'); | ||||
| 
 | ||||
| 	const tld = TLDs[Math.floor(Math.random() * TLDs.length)]; | ||||
| 	return `${name}@${domain}.${tld}`; | ||||
| } | ||||
| 
 | ||||
| export function random_username(): string { | ||||
| 	crypto.getRandomValues(random_byte_buffer); | ||||
| 	return convert_to_words(random_byte_buffer).join('-'); | ||||
| } | ||||
| 
 | ||||
| const BASE_PORT: number = 50_000; | ||||
| const MAX_PORT_OFFSET: number = 10_000; | ||||
| let current_port_offset = 0; | ||||
| function get_next_free_port(): number { | ||||
| 	let free_port: number | undefined = undefined; | ||||
| 	let attempts = 0; | ||||
| 	do { | ||||
| 		const port_to_try: number = BASE_PORT + (current_port_offset++ % MAX_PORT_OFFSET); | ||||
| 
 | ||||
| 		try { | ||||
| 			Deno.listen({ port: port_to_try }).close(); | ||||
| 			free_port = port_to_try; | ||||
| 		} catch (error) { | ||||
| 			if (!(error instanceof Deno.errors.AddrInUse)) { | ||||
| 				throw error; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		++attempts; | ||||
| 
 | ||||
| 		if (attempts % MAX_PORT_OFFSET === 0) { | ||||
| 			console.warn(`Tried all ports at least once while trying to locate a free one, something wrong?`); | ||||
| 		} | ||||
| 	} while (!free_port); | ||||
| 
 | ||||
| 	return free_port; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Interface defining the configuration for an ephemeral server | ||||
|  * @property {string} hostname - hostname bound to, default: 'localhost' | ||||
|  * @property {number} port - port bound to, default: next free port | ||||
|  * @property {SERVER} server - server instance | ||||
|  */ | ||||
| export interface EPHEMERAL_SERVER { | ||||
| 	hostname: string; | ||||
| 	port: number; | ||||
| 	server: SERVER; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Gets an ephemeral Serverus SERVER on an unused port. | ||||
|  * | ||||
|  * @param options Optional SERVER_OPTIONS | ||||
|  * @returns A LISTEN_SERVER_SETUP object with information and a reference to the server | ||||
|  */ | ||||
| export async function get_ephemeral_listen_server(options?: SERVER_OPTIONS): Promise<EPHEMERAL_SERVER> { | ||||
| 	const server_options = { | ||||
| 		...{ | ||||
| 			hostname: 'localhost', | ||||
| 			port: get_next_free_port() | ||||
| 		}, | ||||
| 		...(options ?? {}) | ||||
| 	}; | ||||
| 
 | ||||
| 	const server = new SERVER(server_options); | ||||
| 
 | ||||
| 	const ephemeral_server: EPHEMERAL_SERVER = { | ||||
| 		hostname: server_options.hostname, | ||||
| 		port: server_options.port, | ||||
| 		server: await server.start() | ||||
| 	}; | ||||
| 
 | ||||
| 	return ephemeral_server; | ||||
| } | ||||
							
								
								
									
										121
									
								
								utils/api.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								utils/api.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,121 @@ | |||
| import { getSetCookies } from '@std/http/cookie'; | ||||
| import { generateTotp } from '@stdext/crypto/totp'; | ||||
| 
 | ||||
| export interface API_CLIENT { | ||||
| 	fetch: (url: string, options?: FETCH_OPTIONS, transform?: (obj: any) => any) => Promise<object>; | ||||
| } | ||||
| 
 | ||||
| export type API_CONFIG = { | ||||
| 	protocol: string; | ||||
| 	hostname: string; | ||||
| 	port: number; | ||||
| 	prefix: string; | ||||
| }; | ||||
| 
 | ||||
| const DEFAULT_API_CONFIG: API_CONFIG = { | ||||
| 	protocol: 'http:', | ||||
| 	hostname: 'localhost', | ||||
| 	port: 80, | ||||
| 	prefix: '' | ||||
| }; | ||||
| 
 | ||||
| export interface RETRY_OPTIONS { | ||||
| 	limit: number; | ||||
| 	methods: string[]; | ||||
| 	status_codes: number[]; | ||||
| } | ||||
| 
 | ||||
| export interface FETCH_OPTIONS extends RequestInit { | ||||
| 	retry?: RETRY_OPTIONS; | ||||
| 	json?: Record<string, any>; | ||||
| 	done?: (response: Response) => void; | ||||
| 	session?: Record<string, any>; | ||||
| 	totp_token?: string; | ||||
| } | ||||
| 
 | ||||
| const DEFAULT_TRANSFORM = (response_json: any) => { | ||||
| 	return response_json; | ||||
| }; | ||||
| 
 | ||||
| export function api(api_config?: Record<string, any>): API_CLIENT { | ||||
| 	const config: API_CONFIG = { | ||||
| 		...DEFAULT_API_CONFIG, | ||||
| 		...(api_config ?? {}) | ||||
| 	}; | ||||
| 
 | ||||
| 	return { | ||||
| 		fetch: async (url: string, options?: FETCH_OPTIONS, transform?: (obj: any) => any) => { | ||||
| 			const prefix: string = `${config.protocol}//${config.hostname}:${config.port}${config.prefix}`; | ||||
| 			const retry: RETRY_OPTIONS = options?.retry ?? { | ||||
| 				limit: 0, | ||||
| 				methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], | ||||
| 				status_codes: [500, 502, 503, 504, 521, 522, 524] | ||||
| 			}; | ||||
| 
 | ||||
| 			const content_type = options?.json ? 'application/json' : 'text/plain'; | ||||
| 			const headers = new Headers(options?.headers ?? {}); | ||||
| 			headers.append('accept', 'application/json'); | ||||
| 			if (options?.json || options?.body) { | ||||
| 				headers.append('content-type', content_type); | ||||
| 			} | ||||
| 			if (options?.session) { | ||||
| 				const cookies = getSetCookies(headers); | ||||
| 
 | ||||
| 				cookies.push({ | ||||
| 					name: options.totp_token ?? 'totp', | ||||
| 					value: await generateTotp(options.session.secret), | ||||
| 					maxAge: 30, | ||||
| 					expires: Date.now() + 30_000, | ||||
| 					path: '/' | ||||
| 				}); | ||||
| 
 | ||||
| 				for (const cookie of cookies) { | ||||
| 					headers.append(`x-${cookie.name}`, cookie.value); | ||||
| 				} | ||||
| 				headers.append('cookie', cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')); | ||||
| 			} | ||||
| 
 | ||||
| 			const request_options: RequestInit = { | ||||
| 				body: options?.json ? JSON.stringify(options.json, null, 2) : options?.body, | ||||
| 				method: options?.method ?? 'GET', | ||||
| 				credentials: options?.credentials ?? 'include', | ||||
| 				redirect: options?.redirect ?? 'follow', | ||||
| 				headers | ||||
| 			}; | ||||
| 
 | ||||
| 			const response_transform = transform ?? DEFAULT_TRANSFORM; | ||||
| 
 | ||||
| 			let retries = 0; | ||||
| 			let delay = 1000; | ||||
| 			const resolved_url = `${prefix}${url}`; | ||||
| 			do { | ||||
| 				const response: Response = await fetch(resolved_url, request_options); | ||||
| 				if ( | ||||
| 					retries < retry.limit && retry.status_codes.includes(response.status) && | ||||
| 					retry.methods.includes(request_options.method ?? 'GET') | ||||
| 				) { | ||||
| 					++retries; | ||||
| 					await new Promise((resolve) => setTimeout(resolve, delay)); | ||||
| 					delay *= 2; | ||||
| 					continue; | ||||
| 				} | ||||
| 
 | ||||
| 				if (response.status > 400) { | ||||
| 					const error_response = await response.json(); | ||||
| 					throw new Error('Bad Request', { | ||||
| 						cause: error_response?.cause ?? JSON.stringify(error_response) | ||||
| 					}); | ||||
| 				} | ||||
| 
 | ||||
| 				const data = await response.json(); | ||||
| 				const transformed = await response_transform(data); | ||||
| 
 | ||||
| 				if (options?.done) { | ||||
| 					options.done(response); | ||||
| 				} | ||||
| 
 | ||||
| 				return transformed; | ||||
| 			} while (retries < retry.limit); | ||||
| 		} | ||||
| 	}; | ||||
| } | ||||
							
								
								
									
										13
									
								
								utils/canned_responses.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								utils/canned_responses.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| export const CANNED_RESPONSES: Record<string, () => Response> = { | ||||
| 	permission_denied: (): Response => { | ||||
| 		console.trace('denied'); | ||||
| 		return Response.json({ | ||||
| 			error: { | ||||
| 				message: 'Permission denied.', | ||||
| 				cause: 'permission_denied' | ||||
| 			} | ||||
| 		}, { | ||||
| 			status: 400 | ||||
| 		}); | ||||
| 	} | ||||
| }; | ||||
							
								
								
									
										43
									
								
								utils/prechecks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								utils/prechecks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| import { getCookies } from 'jsr:@std/http/cookie'; | ||||
| import { SESSIONS } from '../models/session.ts'; | ||||
| import { verifyTotp } from 'jsr:@stdext/crypto/totp'; | ||||
| import { USERS } from '../models/user.ts'; | ||||
| import { PERMISSIONS_STORE } from '../models/user_permissions.ts'; | ||||
| import { CANNED_RESPONSES } from './canned_responses.ts'; | ||||
| 
 | ||||
| export type PRECHECK = (req: Request, meta: Record<string, any>) => Promise<Response | undefined> | Response | undefined; | ||||
| export type PRECHECK_TABLE = Record<string, PRECHECK[]>; | ||||
| 
 | ||||
| export const SESSION_ID_TOKEN: string = Deno.env.get('SESSION_ID_TOKEN') ?? 'session_id'; | ||||
| export const SESSION_SECRET_TOKEN: string = Deno.env.get('SESSION_SECRET_TOKEN') ?? 'session_secret'; | ||||
| export const TOTP_TOKEN: string = Deno.env.get('TOTP_TOKEN') ?? 'totp'; | ||||
| 
 | ||||
| export async function get_session(request: Request, meta: Record<string, any>): Promise<undefined> { | ||||
| 	meta.now = meta.now ?? Date.now(); | ||||
| 	meta.cookies = meta.cookies ?? getCookies(request.headers); | ||||
| 	meta.session_id = request.headers.get(`x-${SESSION_ID_TOKEN}`) ?? meta.cookies[SESSION_ID_TOKEN] ?? ''; | ||||
| 	meta.session = meta.session_id?.length ? await SESSIONS.get(meta.session_id) : null; | ||||
| 	meta.valid_session = !!meta.session && meta.now < new Date(meta.session.timestamps.expires).valueOf(); | ||||
| 
 | ||||
| 	meta.request_totp = request.headers.get(`x-${TOTP_TOKEN}`) ?? meta.cookies[TOTP_TOKEN] ?? ''; | ||||
| 	meta.valid_totp = meta.valid_session && meta.session && meta.request_totp | ||||
| 		? await verifyTotp(meta.request_totp, meta.session.secret) | ||||
| 		: false; | ||||
| } | ||||
| 
 | ||||
| export async function get_user(request: Request, meta: Record<string, any>): Promise<undefined> { | ||||
| 	meta.now = meta.now ?? Date.now(); | ||||
| 	meta.cookies = meta.cookies ?? getCookies(request.headers); | ||||
| 
 | ||||
| 	meta.user = meta.valid_totp && meta.session ? await USERS.get(meta.session.user_id) : null; | ||||
| 	meta.user_permissions = meta.valid_totp && meta.session ? await PERMISSIONS_STORE.get(meta.session.user_id) : null; | ||||
| } | ||||
| 
 | ||||
| export function require_user( | ||||
| 	_request: Request, | ||||
| 	meta: Record<string, any> | ||||
| ): undefined | Response { | ||||
| 	if (!meta.user) { | ||||
| 		return CANNED_RESPONSES.permission_denied(); | ||||
| 	} | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue