The implementation of logging errors and making it available as its own app has pretty much come to a close – mostly. There are a few things left to do, but it’s in a working state. Let’s review what happened over the past five days.
- June 20: Error Logging – I turned on a few settings to log errors and parsed a large text file in small batches to allow pagination. A database was designed to hold the errors.
- June 21: PHP Error Database – The errors were moved from the error log into the database. In addition, handlers were set for errors and exceptions that occurred during code execution to log direct to the database. Work started on an Angular web app to view the errors.
- June 22: Styling Log Table – A raw output of data was stylized to show timestamps as dates, truncate long text, Unicode Emoji was chosen to represent different types of errors, and hashed images were created to visually spot similar text.
- June 23: Error Log Details – A modal dialog was displayed showing an individual errors details including a stack trace and various dates of occurrence. An image was created to visually represent the dates relativity to each other over time as well as how many occurrences happened within each period of time.
- June 24: Search, Dialogs, & Deployment – The app gained the ability to search for errors by keywords and quoted phrases that were then highlighted on the search results and error dialog. A feature was added to lookup individual errors by their id. The dialog gained the ability to navigate between the prior and next errors through button clicks and pressing the arrow keys on the keyboard. The application was deployed onto its own separate subdomain.
At the moment, the errors are available for anyone to view. Let’s throw on some authentication. We don’t need a full blown user database just yet. We can use our secrets manager to store the credentials for one account for now. The first step is to create/edit the account. We can add a button and show a dialog to change the username/password and two-factor authentication password. Even if someone figures out my credentials, I want two-factor authentication to keep them from getting further.




Login & 2FA Form Prompt: Microsoft Image Creator
Today I created a form to edit the login credentials and two-factor authentication. I ported my 2FA class from PHP over to TypeScript, and displayed a QR Code to provision the secret into Google Authenticator.
File Structure
Before that, my Angular application is getting a bit messy. Let’s start separating our components into separate folders.
Until I work on a large project with a team, I’m flying blind with how to setup the structure of an Angular project efficiently. Should I make a clear distinction between services and components? In the meantime, I decided to group all related files into individual folders for each component. I separated some of the shared logic into a utils folder that other components can call.
Search Terms
During the movement, I refactored the search logic. Now that we can type in quoted terms, there may be situations where we want to look for a percent sign or underscore. The logic was updated to escape those wildcards and highlight them accordingly in the search results.

Convert search terms into SQL LIKE pattern
/*
* Creates a SQL LIKE pattern based off of search keywords and phrases
*
* Pattern can match anywhere
* Search: Cat
* Matches: He had a cat named Sally
*
* Treats all words as allowing any content between them
* Search: Hello Friend
* Matches: Hello my friend
*
* Can search for quoted phrases
* Search: "Hello Friend"
* Skips: Hello my friend
* Matches: And said Hello Friend
*
* Can mix quoted phrases with keywords
* Search "Hello Friend" Tom
* Matches: Hello friend, my name is Tom
*
* Wildcards _ and % escaped
* Search: 5%
* Matches: The discount was 5%
* Skips: The price was $5.00
* Search: _at
* Matches: created_at = time();
* Skips: created = time();
*
* Order matters
* Search: Hello world
* Matches: And he said hello to the world
* Skip: The world replied Hello
*/
export const sqlLike = (search: string) => {
if (search.trim() === '') return '';
const escaped = escape(search);
const terms = parseTerms(escaped);
return anywhere(terms.join('%'));
}
const escape = (search: string) => search.replaceAll(/([%_])/g, '\\$1');
const anywhere = (search: string) => `%${search}%`;
const parseTerms = (search: string) => {
const terms = [];
// non-quoted + quoted
const pattern = /([^"]*)("([^"]*)")?/g;
const matches = search.matchAll(pattern);
for (const match of matches) {
const keywords = match[1]?.trim() ?? '';
const phrase = match[3]?.trim() ?? '';
if (keywords !== '') {
terms.push(...parseKeywords(keywords));
}
if (phrase !== '') {
terms.push(phrase);
}
}
return terms;
}
const parseKeywords = (search: string) => search
// white-space as space
.replaceAll(/\s+/g, ' ')
// double-space as single
.replaceAll(' ', ' ')
.trim()
.split(' ')
.filter(Boolean);
Highlight search terms
const highlightedClass = 'highlight';
const normalClass = 'no-light';
export const highlightSearchTerms = (text: string, searchText: string) => {
if (searchText.length === 0) return;
let i = 0;
const parts = [];
const pattern = buildPattern(searchText);
if (!pattern.test(text)) {
return [{ text, className: '' }];
}
pattern.lastIndex = 0;
let matches: RegExpExecArray | null = null;
while ((matches = pattern.exec(text)) !== null) {
if (matches === null) break;
if (i++ > 5) break;
const matchIndex = matches.index;
const endIndex = matchIndex + matches[0].length
if (matchIndex !== 0) {
parts.push({
text: text.substring(0, matchIndex),
className: normalClass
});
}
if (matches.length === 1) {
parts.push({
text: matches[0],
className: highlightedClass
});
} else {
for (let n = 1; n < matches.length; n++) {
parts.push({
text: matches[n],
className: n % 2 === 0 ? normalClass : highlightedClass
});
}
}
if (endIndex >= text.length - 1) {
text = '';
} else {
text = text.substring(endIndex);
}
pattern.lastIndex = 0;
}
if (text.length !== 0) {
parts.push({
text,
className: normalClass
});
}
return parts;
}
const buildPattern = (searchText: string) => {
let encoded = searchText
// regex encoding
.replaceAll(/([[.*+?^$`()\\\]])/g, '\\$1')
// quotes
.replaceAll(/"([^"]*?)"/g, (substring: string, ...args: any) => {
// remove quotes and
// prevent space from being
// turned into wildcard later
return args[0].replaceAll(' ', '\\s');
});
if (encoded.includes(' ')) {
// group spaces
encoded = '(' + encoded.replaceAll(/[% ]/g, ')(.*)(') + ')';
}
return new RegExp(encoded, 'gi');
}
MySQLi Exceptions
Well, would you look at that. We have a mysqli_sql_exception with a “Who Knows?” icon. I can change the error type logic to treat anything ending with exception the same way that I treat anything ending with error. However, I could do a bit better for database exceptions and prefix it with an icon to represent a database.
Picking technical emoji is often difficult since there are well known symbols for various terms and technologies, but you are shoe-horned (pigeon holed?) to fit into a limited list.
A database is often represented with a yellow cylinder. Without finding that, I was looking at file cabinets, file folders, and floppy discs. I don’t want people to confuse a database error with a file system error. I decided to go with an emoji representing a card box.

I’m noticing that the error code came through as well as part of the message. I’m not too sure what to make of that. Ideally, you don’t want a column to represent multiple pieces of data in a database. It has to go somewhere… adding it as a detail seems kinda overkill for a number. Maybe it should be part of the error type. Maybe I should add a new nullable column to hold it when provided. Perhaps it is fine where it is at the moment.
Edit Credentials
Time to edit our credentials. What ever we do, it need to be portable to the secrets manager once we set it up as well.
The first thing to do was to create a form to update the username and password. I was able to update the secrets database to include this value. Although the secrets themselves are encrypted, I hashed the salted password for good measure.


I’ve wired up a progress spinner and handling errors from the server. As an added measure, I’ve started learning about using the ReactiveFormsModule. Rather than storing a separate copy of a working draft object to bind form values to, the reactive forms lets me group the form and provides the values separately. I’m also able to wire up quite a few validators. Although Angular offers a pattern validator, it doesn’t offer a way to customize the key, so you’re forced to create some custom validators.
Credentials Form Validators
this.credentialsForm = this.fb.group({
username: new FormControl(data.username, [
Validators.required,
Validators.minLength(3),
alphaNumericOnlyValidator
]),
password: new FormControl(data.password, [
Validators.required,
Validators.minLength(8),
lowercaseRequiredValidator,
uppercaseRequiredValidator,
digitRequiredValidator,
symbolRequiredValidator
])
});
// Validators
const validate = (value: string | FormControlState<string>, pattern: RegExp, key: string) => {
const text = typeof value === 'string' ? value : value.value;
if (!pattern.test(text)) return { [key]: true };
return null;
}
const alphaNumericOnlyValidator = (value: string | FormControlState<string>) => {
return validate(value, /^[a-z\d]*$/i, 'alphaNumericOnly');
}
const digitRequiredValidator = (value: string | FormControlState<string>) => {
return validate(value, /\d/, 'digitRequired');
}
const uppercaseRequiredValidator = (value: string | FormControlState<string>) => {
return validate(value, /[A-Z]/, 'uppercaseRequired');
}
const lowercaseRequiredValidator = (value: string | FormControlState<string>) => {
return validate(value, /[a-z]/, 'lowercaseRequired');
}
const symbolRequiredValidator = (value: string | FormControlState<string>) => {
return validate(value, /[-[!@#$%^&*()_+={}|:";'<>?,.\/\\\]]/, 'symbolRequired');
}
Saving credentials
saveCredentials() {
if (!this.credentialsForm.valid) {
console.error("Invalid form", this.credentialsForm.value);
return;
}
this.savingCredentials = true;
this.savingCredentialsError = undefined;
const {
username,
password
} = this.credentialsForm.value;
this.credentialsService.saveCredentials(
username,
password
)
.subscribe({
next: () => {
this.original.username = username;
this.original.password = password;
}, error: (error: Error) => {
this.savingCredentialsError = error.message;
this.savingCredentials = false;
}, complete: () => {
this.savingCredentials = false;
}
});
}
Displaying validators on Template
<mat-form-field>
<mat-label>Username</mat-label>
<input #usernameInput required matInput autocomplete="username" mat-form-field size="10"
formControlName="username">
<div *ngIf="credentialsForm.get('username')?.hasError('required')">Required.</div>
<div *ngIf="credentialsForm.get('username')?.hasError('minlength')">Too short.</div>
<div *ngIf="credentialsForm.get('username')?.hasError('alphaNumericOnly')">Must be alpha-numeric.</div>
</mat-form-field><br>
<mat-form-field>
<mat-label>Password</mat-label>
<input required matInput autocomplete="new-password" type="password" mat-form-field size="10"
formControlName="password" name="password">
<div *ngIf="credentialsForm.get('password')?.hasError('required')">Required.</div>
<div *ngIf="credentialsForm.get('password')?.hasError('minlength')">Too short.</div>
<div *ngIf="credentialsForm.get('password')?.hasError('uppercaseRequired')">Uppercase required.</div>
<div *ngIf="credentialsForm.get('password')?.hasError('lowercaseRequired')">Lowercase required.</div>
<div *ngIf="credentialsForm.get('password')?.hasError('digitRequired')">Digit required.</div>
<div *ngIf="credentialsForm.get('password')?.hasError('symbolRequired')">Symbol required.</div>
</mat-form-field>
Two-Factor Authentication
The other day I had worked with setting up two-factor authentication using just a secret. Since we are now creating a front-end UI, we can generate a QR code based on the 2FA secret. I was able to setup ng-qrcode fairly quickly and display a QR code. Let’s see if I can translate my 2FA class from PHP into JavaScript to generate a secret, encode it, create a provision url, and confirm the OTP matches before we send it over to the server.

Converting the PHP class over the JavaScript was quick, but sometimes tedious. Typed arguments are in the reverse order. References to static functions are different as PHP is aware of the class it is within, where JavaScript needs to call out the full class name it’s referencing. Also, you can’t declare a private static variable as a constant on a class. I could created a getter, but that just feels like overkill. During conversion, I found a bug where the php code referenced $count instead of $counter for the HOTP. I don’t use counters, so I can see how this got overlooked. The challenging part was working with HMAC in the Web Crypto API and converting buffers to/from 8, 32, and 64 bit integers. Signing and importing keys in the Web Crypto API resulted in OTP generation being an asynchronous call.
Key generation works. The provision url was a bit problematic. I was finding that the google authenticator was failing to recognize the QR code. It recommended that I use my phones camera instead – which opens a different app when scanning 2FA urls.
After changing some things around, I discovered that the problem was with the name of the issuer. It doesn’t like localhost as the issuer. It also has problems with some of the characters when I changed to Error Log (local). Changing issuer to Website got through. I am url encoding the issuer, so it’s not an invalid character in terms of how a URL is formatted. I’m noticing that the Key Uri Format is saying that its represented in ABNF in RFC 5234. Augmented BNF… what is that? Let’s see… we have Backus-Naur Form (BNF). This seems to be a lot to do with network protocols and languages. Is this describing URL encoding, or at least the format of a url? Actually, I think this is a deeper building block, where the structure of a URL may reference ABNF. I think to play it safe, I’ll limit issuer to spaces, letters, and numbers.
More testing… it seems that the first letter must be capitalized. If the issuer is err, then it fails. If the issuer is Err, then the link works. Actually, I’ve been pulling my hair out a bit and decided to put it on the back-burner for now.
With a bit more experimenting with custom validators, I was able to control it the OTP field is disabled based on the enabled state of the slider control. I created a new conditionally required validator where I can pass in a function that evaluates to true or false, allowing the validator to require the value only when true. The form is shaping up fairly well now.

Conditionally Required Validator
<mat-slide-toggle (change)="changeTfaEnabled()" formControlName="enabled"> <mat-label>Enabled</mat-label> </mat-slide-toggle> <mat-form-field appearance="fill"> <mat-label>One-Time Password</mat-label> <input matInput mat-form-field size="10" formControlName="otp"> </mat-form-field>
this.tfaForm = this.fb.group({
otp: new FormControl({
value: '',
disabled: false
}, [
Validators.minLength(6),
Validators.maxLength(6),
Validators.pattern(/^\d+$/),
conditionallyRequredValidator(() => {
if (!this.tfaForm) return false;
return Boolean(this.tfaForm.get('enabled')?.value)
}),
]),
enabled: new FormControl({
value: 'true',
disabled: false
})
})
// swap enabled state
changeTfaEnabled() {
const enabled = Boolean(this.tfaForm.get('enabled')?.value);
if (enabled) {
this.tfaForm.get('otp')?.enable();
} else {
this.tfaForm.get('otp')?.disable();
}
}
// validator
export const conditionallyRequredValidator = (
condition: () => boolean,
key: string = 'required'
) => (value: string | FormControlState<string>) => {
if (!condition()) return null;
const text = valueOf(value);
if (text !== '') return null;
return { [key]: true };
}
Just for giggles, we can validate the OTP on the client-side before we bother sending it to the server. Let’s see if we can display the OTP codes to see if I implemented the logic correctly when I ported the code from PHP.
Well shucks… something is off. The OTP tokens do not match. I wired them up to change when they time out as well.

The OTP generation logic was a bit tough to translate. It’s not a surprise that I got something wrong. Let’s review what we’ve got.
OTP Logic
type digits = 6 | 7 | 8;
type period = 15 | 30 | 60;
type type = 'totp' | 'hotp';
type algorithm = 'sha1' | 'sha256' | 'sha512';
type secret = string;
type otp = string;
private async generate_otp(input: number): Promise<otp> {
const data = int64toBytes(input);
const key = TwoFactorAuth.base32_decode(this.secret);
const hash = await hash_hmac(this.algorithm, data, key);
const view = new DataView(hash.buffer);
const offset = view.getInt8(hash.byteLength - 1) & 0xF;
const value = view.getInt32(offset, true) & 0x7FFFFFFF;
const otp = value % 10 ** this.digits;
return otp.toString().padStart(this.digits, '0');
}
const int64toBytes = (value: number): Uint8Array => {
const bytes = new BigUint64Array(1);
bytes[0] = BigInt(value);
return new Uint8Array(bytes.buffer);
}
async function hash_hmac(
algorithm: algorithm,
message: Uint8Array,
secret: Uint8Array
): Promise<Uint8Array> {
const hash = algorithm.replace('sha', 'SHA-');
const key = await crypto.subtle.importKey(
'raw', secret, { name: 'HMAC', hash }, false, ['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, message);
return new Uint8Array(signature);
}
My logic to convert an int64 to eight bytes is providing the bytes in the wrong order. I should have four zeros before I see any values. I rewrote the int64toBytes conversion to save to a byte array in big endian. Ah hah! The last part was a similar problem. I am now reading the 32 bit integer as big endian byte order as well. My password authenticator matches the OTP number for the current time.
Fixed Endian
private async generate_otp(input: number): Promise<otp> {
const data = int64toBytes(input);
const key = TwoFactorAuth.base32_decode(this.secret);
const hash = await hash_hmac(this.algorithm, data, key);
const view = new DataView(hash.buffer);
const offset = view.getInt8(hash.byteLength - 1) & 0xF;
const value = view.getInt32(offset, false) & 0x7FFFFFFF;
const otp = value % 10 ** this.digits;
return otp.toString().padStart(this.digits, '0');
}
const int64toBytes = (value: number): Uint8Array => {
const buffer = new ArrayBuffer(8);
const dataView = new DataView(buffer, 0, 8);
dataView.setBigInt64(0, BigInt(value), false);
return new Uint8Array(buffer);
}
TwoFactorAuth class in TypeScript
/*
* Otp Class
*
* This class provides functionality for generating and validating one-time passwords (OTPs)
* for two-factor authentication (2FA) using Time-based One-Time Passwords (TOTP) and
* Counter-based One-Time Passwords (HOTP) algorithms.
*
* June 25, 2024: Ported from PHP to TypeScript
*/
type digits = 6 | 7 | 8;
type period = 15 | 30 | 60;
type type = 'totp' | 'hotp';
type algorithm = 'sha1' | 'sha256' | 'sha512';
type secret = string;
type otp = string;
export class TwoFactorAuth {
private static BASE_32_SYMBOLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
private type: type;
private algorithm: algorithm;
private digits: digits;
private period: period;
private secret: string;
/**
* Constructor for the Otp class.
*
* Initializes a new instance of the Otp class with the provided parameters.
*
* @param string $secret The secret key used for OTP generation. It must be in Base32 format.
* @param string $type The type of OTP authentication. Defaults to 'totp' (Time-based OTP). Valid values are 'totp' and 'hotp'.
* @param int $period The time period in seconds for TOTP authentication. Defaults to 30 seconds.
* @param int $digits The number of digits in the generated OTP. Defaults to 6 digits. Valid values are 6 to 8.
* @param string $algorithm The cryptographic hash algorithm used for OTP generation.
* Defaults to 'sha1'. Valid values are 'sha1', 'sha256', and 'sha512'.
* @throws Exception If the secret key is empty, invalid, or less than 80 bits.
* @throws Exception If the specified digits, algorithm, type, or period is invalid.
*/
constructor(
secret: secret,
type: type = 'totp',
period: period = 30,
digits: digits = 6,
algorithm: algorithm = 'sha1'
) {
TwoFactorAuth.checkSecret(secret);
this.secret = secret;
this.period = period;
this.digits = digits;
this.algorithm = algorithm;
this.type = type;
}
public static checkSecret(secret: secret) {
if (secret.trim() === '') {
throw new Error("Missing secret.");
}
const bits = secret.length * 5;
if (bits < 80) {
throw new Error("Expected 80 bit secret or higher.");
}
if (!TwoFactorAuth.valid_base32(secret)) {
throw new Error("Invalid secret.");
}
}
/**
* Generate a random secret key for OTP authentication.
*
* This method generates a random secret key with a specified number of bits,
* which is used for OTP authentication. The secret key is generated using
* cryptographically secure random bytes and encoded in Base32 format.
*
* @param int $bits The number of bits for the secret key. Must be 80 or higher
* and a multiple of 8. Default is 128.
* @return string The generated random secret key.
* @throws Exception If the number of bits is less than 80 or not a multiple of 8.
*/
public static generate_secret(bits: number = 128): secret {
if (bits < 80) {
throw new Error("Expected 80 bit secret or higher.");
} else if (bits % 8 !== 0) {
throw new Error("Bits must be a multiple of 8.");
}
const length = Math.ceil(bits / 5);
let secret = '';
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
for (let i = 0; i < length; i++) {
const index = bytes[i] % 32;
secret += TwoFactorAuth.BASE_32_SYMBOLS[index];
}
return secret;
}
/**
* Retrieve the OTP at a specified offset.
*
* This method calculates the OTP at an offset relative to the current time or counter.
* For TOTP (Time-based One-Time Password), it calculates the OTP at an offset of time
* from the current time. For HOTP (Counter-based One-Time Password), it calculates the
* OTP at an offset of counter steps from the current counter value.
*
* @param int $offset The offset at which to retrieve the OTP.
* @param int|null $input The 64 bit base time or counter value. If not provided, the current time
* (for TOTP) or counter value (for HOTP) is used.
* @return string The calculated OTP.
* @throws Exception If the offset or input value is not an integer.
* @throws Exception If the input is not specified for HOTP.
* @throws Exception If the secret contains invalid Base32 characters.
*/
public async get_relative_otp(offset: number, input?: number): Promise<otp> {
if (this.type === 'totp') {
if (!input) {
input = new Date().getTime() / 1000;
}
input += offset * this.period;
return this.time_based_otp(input);
} else {
if (!input) {
throw new Error("Expected input to be provided.");
}
return this.counter_based_otp(input + offset);
}
}
/**
* Generate a One-Time Password (OTP).
*
* This method generates a One-Time Password (OTP) based on the configured type
* of OTP algorithm. If the type is TOTP (Time-based One-Time Password), it generates
* a password based on the current time (or provided time if specified). If the type
* is HOTP (Counter-based One-Time Password), it generates a password based on the
* provided counter value.
*
* @param int|null $input The 64 bit base time (for TOTP) or counter value (for HOTP).
* If not provided, the current time (for TOTP) or counter value
* (for HOTP) is used.
* @return string The generated One-Time Password (OTP).
* @throws Exception If the input is not specified for HOTP.
* @throws Exception If the input is less than zero to TOTP.
* @throws Exception If the secret contains invalid Base32 characters.
*/
public async otp(input?: number): Promise<otp> {
if (this.type === 'totp') {
return this.time_based_otp(input);
} else if (input) {
return this.counter_based_otp(input);
} else {
throw new Error('Input required for counter-based OTP');
}
}
/**
* Generate a Time-based One-Time Password (TOTP).
*
* This method generates a Time-based One-Time Password (TOTP) based on the specified
* time or the current time if not provided. The TOTP is calculated based on the time
* divided by the configured period and then passed to the generate_otp method for
* OTP generation.
*
* @param int|null $time The 64-bit Unix timestamp representing the base time for OTP generation.
* If not provided, the current time is used.
* @return string The generated Time-based One-Time Password (TOTP).
* @throws Exception If the secret contains invalid Base32 characters.
*/
public async time_based_otp(time?: number): Promise<otp> {
if (!time) {
time = new Date().getTime() / 1000;
}
const timestamp = Math.floor(time / this.period);
return this.generate_otp(timestamp);
}
public nextPeriod(): number {
const now = new Date().getTime();
let timestamp = Math.floor(now / 1000 / this.period);
const next = (timestamp + 1) * this.period * 1000;
return next - now;
}
/**
* Generate a Counter-based One-Time Password (HOTP).
*
* This method generates a Counter-based One-Time Password (HOTP) based on the specified
* counter. The HOTP is calculated based on the provided counter and then passed to the
* generate_otp method for OTP generation.
*
* @param int $counter The 64-bit counter value used for OTP generation.
* @return string The generated Counter-based One-Time Password (HOTP).
* @throws Exception If the counter is not specified.
*/
public async counter_based_otp(counter: number): Promise<otp> {
return this.generate_otp(counter);
}
/**
* Generate a One-Time Password (OTP) based on the input value.
*
* This method generates a One-Time Password (OTP) based on the provided input value.
* The input value is first checked for validity and then used to calculate the OTP
* using the HMAC-based One-Time Password (HOTP) algorithm. The resulting OTP is then
* returned after padding it to match the specified number of digits.
*
* @param int $input The 64-bit value used for OTP generation.
* @return string The generated One-Time Password (OTP).
* @throws Exception If the input value is not specified or is less than zero.
* @throws Exception If the secret contains invalid Base32 characters.
*/
private async generate_otp(input: number): Promise<otp> {
const data = this.int64toBytes(input);
const key = TwoFactorAuth.base32_decode(this.secret);
const hash = await this.hash_hmac(this.algorithm, data, key);
const view = new DataView(hash.buffer);
const offset = view.getInt8(hash.byteLength - 1) & 0xF;
const value = view.getInt32(offset, false) & 0x7FFFFFFF;
const otp = value % 10 ** this.digits;
return otp.toString().padStart(this.digits, '0');
}
/**
* Converts a 64 bit integer to a byte array
* @param value 64bit value to convert to byte array
* @returns Uint8Array[8] Array of 8 bytes in big endian order
*/
private int64toBytes(value: number): Uint8Array {
const buffer = new ArrayBuffer(8);
const dataView = new DataView(buffer, 0, 8);
dataView.setBigInt64(0, BigInt(value), false);
return new Uint8Array(buffer);
}
/**
*
* @param algorithm The algorithm used to hash the message
* @param message The message to hash
* @param secret The secret to apply to the hash
* @returns promise of a hashed message
*/
private async hash_hmac(
algorithm: algorithm,
message: Uint8Array,
secret: Uint8Array
): Promise<Uint8Array> {
const name = 'HMAC';
const hash = algorithm.replace('sha', 'SHA-');
const key = await crypto.subtle.importKey(
'raw', secret, { name, hash }, false, ['sign']
);
const signature = await crypto.subtle.sign(name, key, message);
return new Uint8Array(signature);
}
/**
* Generate a URI for provisioning a new OTP token.
*
* This method generates a URI that can be used to provision a new OTP token
* with the specified parameters. The generated URI follows the Key URI Format
* specified by the Google Authenticator documentation. It includes parameters
* such as the secret key, algorithm, digits, period (for TOTP), counter (for HOTP),
* account name, and issuer name.
*
* @param int|null $counter The 64 bit initial counter value for HOTP tokens.
* @param string|null $account The account name associated with the OTP token.
* @param string|null $issuer The issuer name associated with the OTP token.
* @return string The URI for provisioning the OTP token.
* @throws Exception If there are any validation errors or invalid parameter values.
*/
public generate_provisioning_uri({ counter, account, issuer }: { counter?: number, account?: string, issuer?: string }): string {
let url = 'otpauth://';
url += encodeURIComponent(this.type) + '/';
// Label Prefix
const hasIssuer = issuer && issuer.trim().length !== 0;
if (hasIssuer) {
if (issuer.includes(':')) {
throw new Error("Invalid issuer contains a colon.");
}
url += encodeURIComponent(issuer);
}
// Label Suffix
const hasAccount = account && account.trim().length !== 0;
if (hasAccount) {
if (account?.includes(':')) {
throw new Error("Invalid account contains a colon.");
}
if (hasIssuer) {
url += ":";
}
url += encodeURIComponent(account);
}
url += "?secret=" + encodeURIComponent(this.secret);
if (hasIssuer) {
url += "&issuer=" + encodeURIComponent(issuer);
}
// Optional Parameters are excluded if they match default values
if (this.algorithm !== "sha1") {
url += "&algorithm=" + encodeURIComponent(this.algorithm);
}
if (this.digits !== 6) {
url += "&digits=" + encodeURIComponent(this.digits);
}
switch (this.type) {
case 'totp':
if (this.period !== 30) {
url += "&period=" + encodeURIComponent(this.period);
}
if (counter) {
throw new Error('Initial counter is only for provisioning HOTP.');
}
break;
case 'hotp':
if (!counter) {
throw new Error('Initial counter is required for HOTP.');
}
url += "&counter=" + encodeURIComponent(counter);
break;
default:
throw new Error("Invalid type: $this->type.");
}
return url;
}
/**
* Decode a Base32 encoded string.
*
* This method decodes a Base32 encoded string into its original binary data.
* It is used internally for decoding secrets provided in Base32 format.
*
* @param string $encoded The Base32 encoded string to decode.
* @return string The decoded binary data.
* @throws Exception If the input contains invalid Base32 characters.
*/
private static base32_decode(encoded: string): Uint8Array {
const decoded: number[] = [];
const length = encoded.length;
let buffer = 0;
let bufferSize = 0;
for (let i = 0; i < length; i++) {
const char = encoded[i];
let value = TwoFactorAuth.BASE_32_SYMBOLS.indexOf(char);
if (value === -1) {
throw new Error('Invalid base32 value.');
}
buffer = (buffer << 5) | value;
bufferSize += 5;
if (bufferSize >= 8) {
decoded.push((buffer >> (bufferSize - 8)) & 0xFF);
bufferSize -= 8;
}
}
return Uint8Array.from(decoded);
}
/**
* Check if a string is a valid Base32 encoded string.
*
* This method verifies whether a given string is a valid Base32 encoded string.
* It checks if all characters in the input string belong to the Base32 character set.
*
* @param string $encoded The string to validate as Base32 encoded.
* @return bool True if the string is a valid Base32 encoded string, false otherwise.
*/
public static valid_base32(encoded: string): boolean {
const length = encoded.length;
for (let i = 0; i < length; i++) {
const char = encoded[i];
const value = TwoFactorAuth.BASE_32_SYMBOLS.indexOf(char);
if (value === -1) {
return false;
}
}
return true;
}
}
Okay, let’s actually wire this up to check against the OTP before we send off to the server.
Ack! VS code is acting weird. It doesn’t recognize text was deleted. Closing and opening files doesn’t fix the problem. I tried changing a console.log statement to throw an error instead.

Okay, I got everything wired up to save. Unfortunately, it seems like nothing happened. Sure – I have a brief spinner, but that’s it. I need to tell the user that something happened. Lucky for us, Angular provides a snack bar.

Let’s see if we can get the actual message from the server to show up.

Uh oh. Something’s off. I was actually enabling it. Time to connect some dots. I ran into a typo when reading my form values. I tried to get this.tfaForm.value.enable instead of enabled. It was evaluating as if I wanted to disable the 2FA.
I went ahead and made a few more fixes to the toast color and fixed another bug in the saving of 2FA on the server. I finally got a successful result.

Sassy CSS For Snack bar
.mat-mdc-snack-bar-container {
&.error {
.mat-mdc-snackbar-surface {
background-color: rgb(102, 15, 15);
}
}
&.success {
.mat-mdc-snackbar-surface {
background-color: rgb(12, 116, 40);
}
}
}
How generic. Also, the snack bar isn’t close to the dialog, so it feels like it’s context is a bit off. Let’s provide a little more detail about what was successful.

And we can do the same for the login credentials.

Wrap Up
My main goal was to lock-down the site with a login form. I’m a bit disappointed that I didn’t get far enough to implement the authentication. I have a bit of a learning curve onboarding with Angular and Google Authenticator seems a bit buggy with how is scans various QR codes. There are a few rabbit holes that I went down that weren’t mentioned, such as trying to get the forms to be elevated with mat-elevation-z4 and why I couldn’t import @angular/material/theming in my SCSS files. On top of that, my VS Code IDE seems to get buggy if I have it open for a few days without closing it. Excuses… I can come up with the reasons why I didn’t get as much done today as I liked.
Let’s evaluate the progress made today.
- Restructured Angular project files
- Changed search terms to allow percent % and underscore _ to be searched
- Display MySQLi exceptions with a card index emoji and red flag
- Display unknown exception types that end with
exceptionwith a red flag - Create endpoints to change login credentials and 2FA settings
- Changed from ngModel binding to Reactive Forms
- Added a form to edit username/password
- Added a form to enable/disable and change two-factor authentication
- Display a QR code to provision 2FA secrets
- Handle errors from API requests
- Ported Two-Factor Authentication class from PHP to TypeScript
- Verify 2FA on the client before making an API request
- Create custom validators to
- Only allow alpha-numeric characters
- Require 1 or more digits
- Require 1 or more upper-case letters
- Require 1 or more lower-case letters
- Require 1 or more symbols
- Require a value if a condition is met
- Display brief messages with a snack bar component and style for errors/success
Tomorrow’s Goals
Tomorrow is going to be a limited day as I need to be in Stanley, VA for a few hours. However, I am hoping that the implementation of authentication will be completed.
- Display login form
- Prompt for OTP token if 2FA enabled
- Create endpoint to determine if 2FA enabled for user account
- Only needed to display if 2FA is enabled
- Create endpoint to authenticate with login credentials and return if 2FA is to be prompted
- Create endpoint to validate 2FA OTP
- Use authorization bearer tokens to maintain session
- Guard all other endpoints with bearer token authentication
- Log user out if API request indicates bearer token required – but keep url route/query string parameters
- Add logout button
- Remember bearer token in local storage or cookie
- Consider cookie policy implementation

