captchajs.ts
1 import { createHash } from 'node:crypto'; 2 3 import randomstring from "randomstring"; 4 import { MemoryRandomStore } from "./memory_randomstore"; 5 import { CaptchaJsOptions, GetAudioUrlOptions, GetImageUrlOptions, RandomStore } from "./index"; 6 import * as util from "node:util"; 7 8 const logger = util.debuglog('captchajs:captcha'); 9 10 const standardAlphabet = "abcdefghijklmnopqrstuvwxyz"; 11 12 const defaultOptions: CaptchaJsOptions = { 13 // These are defined in default but not expected ever to be used. 14 client: "demo", 15 secret: "secret", 16 numberOfLetters: 6, 17 width: 240, 18 height: 80, 19 alphabet: standardAlphabet 20 }; 21 22 export class CaptchaJs implements CaptchaJs { 23 private opts: CaptchaJsOptions; 24 private randomStore: RandomStore; 25 26 constructor(options: CaptchaJsOptions) { 27 logger("Constructing with options", options); 28 if (!options.client) { 29 throw new Error("No client ID provided"); 30 } 31 if (!options.secret) { 32 throw new Error("No secret provided"); 33 } 34 if (options.alphabet === "") { 35 throw new Error("Can't use an empty alphabet"); 36 } 37 if (options.numberOfLetters === 0) { 38 throw new Error("Need at least one letter"); 39 } 40 41 this.opts = Object.assign({}, defaultOptions, options); 42 logger("this.opts", this.opts); 43 this.randomStore = options.randomStore ?? new MemoryRandomStore(); 44 logger("this.randomStore", this.randomStore); 45 } 46 47 getRandomString(): string { 48 let rs; 49 // console.log("getRandomString in"); 50 do { 51 rs = randomstring.generate({ length: 40 }); 52 // console.log("Trying random string", rs); 53 } while (!this.randomStore.addRandom(rs)); 54 55 // console.log("Returning", rs) 56 return rs; 57 } 58 59 makePassword(random: string): string { 60 if (!random) { 61 throw new Error("No random string supplied"); 62 } 63 const concatString = this.opts.secret + random; 64 const hash = createHash('md5'); 65 hash.write(concatString); 66 const substring = hash.digest().slice(0, this.opts.numberOfLetters); 67 // TODO rewrite with map() or similar 68 let password = ""; 69 for (const c of substring) { 70 // 'alphabet' will exist by now, we'll have used the default if needed. 71 password += this.opts.alphabet![c % this.opts.alphabet!.length] 72 } 73 74 return password; 75 } 76 77 getImageUrl(opts: GetImageUrlOptions): string { 78 const base = opts?.baseURL || "https://image.captchas.net/"; 79 let url = `${base}?client=${this.opts.client}&random=${opts.randomString}` 80 if (this.opts.alphabet !== standardAlphabet) { 81 url += `&alphabet=${this.opts.alphabet}`; 82 } 83 if (this.opts.numberOfLetters !== 6) { 84 url += `&letters=${this.opts.numberOfLetters}`; 85 } 86 if (this.opts.width !== 240) { 87 url += `&width=${this.opts.width}`; 88 } 89 if (this.opts.height !== 80) { 90 url += `&height=${this.opts.height}`; 91 } 92 return url; 93 } 94 95 getAudioUrl(opts: GetAudioUrlOptions): string { 96 const base = opts?.baseURL || "https://audio.captchas.net/"; 97 let url = `${base}?client=${this.opts.client}&random=${opts.randomString}`; 98 if (this.opts.alphabet !== standardAlphabet) { 99 url += `&alphabet=${this.opts.alphabet}`; 100 } 101 if (this.opts.numberOfLetters !== 6) { 102 url += `&letters=${this.opts.numberOfLetters}`; 103 } 104 return url; 105 } 106 107 validateRandomString(randomString: string, invalidate = true): boolean { 108 if (!randomString) { 109 logger("No random string"); 110 return false; 111 } 112 113 logger("Validating random string", randomString); 114 return this.randomStore.validateRandom(randomString, invalidate); 115 } 116 117 verifyPassword(randomString: string, password: string): boolean { 118 if (!password) { 119 logger("No password"); 120 return false; 121 } 122 if (!randomString) { 123 logger("No random string"); 124 return false; 125 } 126 127 logger("Verifying password", password, "using random string", randomString); 128 if (password.length != this.opts.numberOfLetters) { 129 logger("Password length mismatch, reject.") 130 return false; 131 } 132 const ourPassword = this.makePassword(randomString); 133 logger("Calculated password", ourPassword); 134 return ourPassword === password; 135 } 136 }