feature: initial commit
This commit is contained in:
commit
2c27f003c9
15 changed files with 1601 additions and 0 deletions
16
public/api/auth/README.md
Normal file
16
public/api/auth/README.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
# /api/auth
|
||||
|
||||
Authentication for the service.
|
||||
|
||||
## POST /api/auth
|
||||
|
||||
Log into the service.
|
||||
|
||||
```
|
||||
{
|
||||
email?: string; // either email or username must be specified
|
||||
username?: string;
|
||||
password_hash: string; //should be a base64-encoded SHA-256 hash of the user's client-entered pw
|
||||
totp?: string; // TOTP if configured on account
|
||||
}
|
||||
```
|
192
public/api/auth/index.ts
Normal file
192
public/api/auth/index.ts
Normal file
|
@ -0,0 +1,192 @@
|
|||
import { PASSWORD_ENTRY_STORE } from '../../../models/password_entry.ts';
|
||||
import { USER, USER_STORE } from '../../../models/user.ts';
|
||||
import { generateSecret } from 'jsr:@stdext/crypto/utils';
|
||||
import { encodeBase32 } from 'jsr:@std/encoding';
|
||||
import { verify } from 'jsr:@stdext/crypto/hash';
|
||||
import lurid from 'jsr:@andyburke/lurid';
|
||||
import { SESSION, SESSIONS } from '../../../models/session.ts';
|
||||
import { TOTP_ENTRY_STORE } 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';
|
||||
|
||||
const DEFAULT_SESSION_TIME: number = 60 * 60; // 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'
|
||||
? encodeBase64(
|
||||
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password ?? ''))
|
||||
)
|
||||
: undefined);
|
||||
const totp: string | undefined = body.totp;
|
||||
|
||||
if ((!email.length && !username.length) || (email.length && username.length)) {
|
||||
return Response.json({
|
||||
error: {
|
||||
message: 'You must specify either an email or username to log in.',
|
||||
cause: 'email_or_username_required'
|
||||
}
|
||||
}, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
// password has should be a base64-encoded SHA-256 hash
|
||||
if (typeof password_hash !== 'string') {
|
||||
return Response.json({
|
||||
error: {
|
||||
message: 'invalid password hash',
|
||||
cause: 'invalid_password_hash'
|
||||
}
|
||||
}, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
let user: USER | undefined = undefined;
|
||||
if (email.length) {
|
||||
user = (await USER_STORE.find({
|
||||
email
|
||||
})).shift();
|
||||
} else if (username.length) {
|
||||
user = (await USER_STORE.find({
|
||||
username
|
||||
})).shift();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return Response.json({
|
||||
error: {
|
||||
message: 'Could not locate an account with this email or username.',
|
||||
cause: 'missing_account'
|
||||
}
|
||||
}, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
const password_entry = await PASSWORD_ENTRY_STORE.get(user.id);
|
||||
if (!password_entry) {
|
||||
return Response.json({
|
||||
error: {
|
||||
message: 'Missing password entry for this account, please contact support.',
|
||||
cause: 'missing_password_entry'
|
||||
}
|
||||
}, {
|
||||
status: 500
|
||||
});
|
||||
}
|
||||
|
||||
const verified = verify('bcrypt', `${password_hash}${password_entry.salt}`, password_entry.hash);
|
||||
if (!verified) {
|
||||
return Response.json({
|
||||
error: {
|
||||
message: 'Incorrect password.',
|
||||
cause: 'incorrect_password'
|
||||
}
|
||||
}, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
const totp_entry = await TOTP_ENTRY_STORE.get(user.id);
|
||||
if (totp_entry) {
|
||||
if (typeof totp !== 'string' || !totp.length) {
|
||||
return Response.json({
|
||||
two_factor: true,
|
||||
totp: true
|
||||
}, {
|
||||
status: 202,
|
||||
headers: {
|
||||
'Set-Cookie': `checklist_observer_totp_user_id=${user.id}; Path=/; Max-Age=300`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const valid_totp: boolean = await verifyTotp(totp, totp_entry.secret);
|
||||
if (!valid_totp) {
|
||||
return Response.json({
|
||||
error: {
|
||||
message: 'Incorrect TOTP.',
|
||||
cause: 'incorrect_totp'
|
||||
}
|
||||
}, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const session_result: SESSION_RESULT = await get_session({
|
||||
user,
|
||||
expires: body.session?.expires
|
||||
});
|
||||
|
||||
// TODO: verify this redirect is relative?
|
||||
const headers = session_result.headers;
|
||||
let status = 201;
|
||||
if (typeof meta?.query?.redirect === 'string') {
|
||||
const url = new URL(req.url);
|
||||
session_result.headers.append('location', `${url.origin}${meta.query.redirect}`);
|
||||
status = 302;
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
user,
|
||||
session: session_result.session
|
||||
}, {
|
||||
status,
|
||||
headers
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json({
|
||||
error: {
|
||||
message: (error as Error).message ?? 'Unknown Error!',
|
||||
cause: (error as Error).cause ?? 'unknown'
|
||||
}
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export type SESSION_RESULT = {
|
||||
session: SESSION;
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type SESSION_INFO = {
|
||||
user: USER;
|
||||
expires: string | undefined;
|
||||
};
|
||||
|
||||
export async function get_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();
|
||||
|
||||
const session: SESSION = {
|
||||
id: lurid(),
|
||||
user_id: session_settings.user.id,
|
||||
secret: encodeBase32(generateSecret()),
|
||||
timestamps: {
|
||||
created: now,
|
||||
expires,
|
||||
ended: ''
|
||||
}
|
||||
};
|
||||
|
||||
await SESSIONS.create(session);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append('Set-Cookie', `checklist_observer_session_id=${session.id}; Path=/; Expires=${expires}`);
|
||||
|
||||
return {
|
||||
session,
|
||||
headers
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue