Added API key authentication.
This commit is contained in:
parent
da893c1a76
commit
e8864ce35b
|
@ -29,5 +29,5 @@ body {
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: var(--primary-light-20);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- Generated by IcoMoon.io -->
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M15.25 3h-9c-0.412 0-0.989 0.239-1.28 0.53l-4.439 4.439c-0.292 0.292-0.292 0.769 0 1.061l4.439 4.439c0.292 0.292 0.868 0.53 1.28 0.53h9c0.412 0 0.75-0.338 0.75-0.75v-9.5c0-0.413-0.338-0.75-0.75-0.75zM14 10.5l-1.5 1.5-2-2-2 2-1.5-1.5 2-2-2-2 1.5-1.5 2 2 2-2 1.5 1.5-2 2 2 2z"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 430 B |
|
@ -0,0 +1,5 @@
|
||||||
|
<!-- Generated by IcoMoon.io -->
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8.414l-1.414-1.414-2.086 2.086-2.086-2.086-1.414 1.414 2.086 2.086-2.086 2.086 1.414 1.414 2.086-2.086 2.086 2.086 1.414-1.414-2.086-2.086z"></path>
|
||||||
|
<path d="M8 13.421c-0.752 0.173-1.611 0.266-2.5 0.266-1.242 0-2.429-0.181-3.342-0.51-0.763-0.275-1.074-0.562-1.158-0.677v-2.594c0.995 0.643 2.64 1.062 4.5 1.062 0.9 0 1.75-0.098 2.5-0.273v-1.306c-0.752 0.173-1.611 0.266-2.5 0.266-1.242 0-2.429-0.181-3.342-0.51-0.762-0.275-1.074-0.562-1.158-0.677v-2.531c0.995 0.643 2.64 1.062 4.5 1.062 3.038 0 5.5-1.119 5.5-2.5s-2.462-2.5-5.5-2.5-5.5 1.119-5.5 2.5v8c0 1.381 2.462 2.5 5.5 2.5 0.9 0 1.75-0.098 2.5-0.273v-1.306zM2.402 3.823c0.846-0.329 1.946-0.51 3.098-0.51s2.252 0.181 3.098 0.51c0.707 0.275 0.995 0.562 1.074 0.677-0.078 0.115-0.367 0.402-1.074 0.677-0.846 0.329-1.946 0.51-3.098 0.51s-2.252-0.181-3.098-0.51c-0.707-0.275-0.996-0.562-1.074-0.677 0.078-0.115 0.367-0.402 1.074-0.677z"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.0 KiB |
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- Generated by IcoMoon.io -->
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M15 4h-14l1-2h5.5l0.5 1h6.5l0.5 1zM0 5l1 10h14l1-10h-16zM11.25 7.811l-2.189 2.189 2.189 2.189-1.061 1.061-2.189-2.189-2.189 2.189-1.061-1.061 2.189-2.189-2.189-2.189 1.061-1.061 2.189 2.189 2.189-2.189 1.061 1.061z"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 371 B |
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- Generated by IcoMoon.io -->
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="17" height="16" viewBox="0 0 17 16">
|
||||||
|
<path d="M10 1c3.866 0 7 3.134 7 7s-3.134 7-7 7v-1.5c1.469 0 2.85-0.572 3.889-1.611s1.611-2.42 1.611-3.889c0-1.469-0.572-2.85-1.611-3.889s-2.42-1.611-3.889-1.611c-1.469 0-2.85 0.572-3.889 1.611-0.799 0.799-1.322 1.801-1.52 2.889h2.909l-3.5 4-3.5-4h2.571c0.485-3.392 3.402-6 6.929-6zM13 7v2h-4v-5h2v3z"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 448 B |
|
@ -0,0 +1,4 @@
|
||||||
|
<!-- Generated by IcoMoon.io -->
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M5 1v1.155l-2.619 0.368 0.17 1.211-2.551 0.732 3.308 11.535 10.189-2.921 0.558-0.079h1.945v-12h-11zM3.929 14.879l-2.808-9.793 1.558-0.447 1.373 9.766 2.997-0.421-3.119 0.894zM4.822 13.382l-1.418-10.088 1.595-0.224v9.93h2.543l-2.721 0.382zM15 12h-9v-10h9v10zM13 8.939v1.061h-1.061l-1.439-1.439-1.439 1.439h-1.061v-1.061l1.439-1.439-1.439-1.439v-1.061h1.061l1.439 1.439 1.439-1.439h1.061v1.061l-1.439 1.439z"></path>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 562 B |
|
@ -23,7 +23,6 @@ module.exports = {
|
||||||
maxQueryTime: 10000,
|
maxQueryTime: 10000,
|
||||||
},
|
},
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
graphiql: false,
|
|
||||||
pool: {
|
pool: {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 20,
|
max: 20,
|
||||||
|
@ -64,6 +63,12 @@ module.exports = {
|
||||||
usernameLength: [2, 24],
|
usernameLength: [2, 24],
|
||||||
usernamePattern: /^[a-zA-Z0-9_-]+$/,
|
usernamePattern: /^[a-zA-Z0-9_-]+$/,
|
||||||
},
|
},
|
||||||
|
apiAccess: {
|
||||||
|
graphqlEnabled: true,
|
||||||
|
keySize: 24, // bytes
|
||||||
|
keyLimit: 5, // max keys per user
|
||||||
|
keyCooldown: 1, // minutes between key generation
|
||||||
|
},
|
||||||
psa: {
|
psa: {
|
||||||
text: 'Welcome to traxxx!', // html enabled
|
text: 'Welcome to traxxx!', // html enabled
|
||||||
type: 'notice', // notice, alert
|
type: 'notice', // notice, alert
|
||||||
|
|
|
@ -0,0 +1,271 @@
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="keys-header">
|
||||||
|
<h2 class="heading">API keys</h2>
|
||||||
|
|
||||||
|
<div class="keys-actions">
|
||||||
|
<Icon
|
||||||
|
v-tooltip="'Flush all keys'"
|
||||||
|
icon="stack-cancel"
|
||||||
|
@click="flushKeys"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
@click="createKey"
|
||||||
|
>New key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="newKey"
|
||||||
|
class="newkey"
|
||||||
|
>
|
||||||
|
<p class="key-info">
|
||||||
|
Your new key identified by <strong>{{ newKey.identifier }}</strong> is
|
||||||
|
<input
|
||||||
|
:value="newKey.key"
|
||||||
|
class="input"
|
||||||
|
@click="copyKey"
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="key-info">Please store this key securely, you will <strong>not</strong> be able to retrieve it later. If you lose it, you must generate a new key.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="keys.length > 0"
|
||||||
|
class="keys nolist"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="key in keys"
|
||||||
|
:key="`key-${key.id}`"
|
||||||
|
class="key"
|
||||||
|
>
|
||||||
|
<div class="key-row key-header">
|
||||||
|
<strong class="key-value key-identifier ellipsis">{{ key.identifier }}</strong>
|
||||||
|
|
||||||
|
<span class="key-actions">
|
||||||
|
<Icon
|
||||||
|
icon="bin"
|
||||||
|
@click="removeKey(key)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-row key-details">
|
||||||
|
<span class="key-value key-created">
|
||||||
|
<Icon icon="plus-circle" />
|
||||||
|
|
||||||
|
<time
|
||||||
|
v-tooltip="format(key.createdAt, 'yyyy-MM-dd hh:mm:ss')"
|
||||||
|
:datetime="key.createdAt.toISOString()"
|
||||||
|
>{{ formatDistanceToNowStrict(key.createdAt) }} ago</time>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="key-value key-used">
|
||||||
|
<Icon icon="history" />
|
||||||
|
|
||||||
|
<template v-if="key.lastUsedAt">
|
||||||
|
<time
|
||||||
|
v-tooltip="`${key.lastUsedIp} at ${format(key.lastUsedAt, 'yyyy-MM-dd hh:mm:ss')}`"
|
||||||
|
:datetime="key.lastUsedAt.toISOString()"
|
||||||
|
>{{ formatDistanceToNowStrict(key.lastUsedAt) }} ago</time>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>Never</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="keys.length > 0"
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<h3 class="info-heading">HTTP headers</h3>
|
||||||
|
|
||||||
|
<code class="headers">
|
||||||
|
Api-User: {{ user.id }}<br>
|
||||||
|
Api-Key: YourSecurelyStoredApiKey12345678
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, inject } from 'vue';
|
||||||
|
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { get, post, del } from '#/src/api.js';
|
||||||
|
import events from '#/src/events.js';
|
||||||
|
|
||||||
|
const pageContext = inject('pageContext');
|
||||||
|
const user = pageContext.user;
|
||||||
|
const keys = ref(pageContext.pageProps.keys);
|
||||||
|
const newKey = ref(null);
|
||||||
|
|
||||||
|
async function createKey() {
|
||||||
|
const key = await post('/keys', null, {
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
newKey.value = key;
|
||||||
|
keys.value = await get('/me/keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeKey(key) {
|
||||||
|
if (confirm(`Are you sure you want to remove API key '${key.identifier}' (${format(key.createdAt, 'yyyy-MM-dd hh:mm')})? It can not be restored.`)) { // eslint-disable-line no-restricted-globals, no-alert
|
||||||
|
newKey.value = null;
|
||||||
|
|
||||||
|
await del(`/me/keys/${key.identifier}`);
|
||||||
|
|
||||||
|
keys.value = await get('/me/keys');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushKeys() {
|
||||||
|
if (confirm('Are you sure you want to remove ALL your API keys? They can not be restored.')) { // eslint-disable-line no-restricted-globals, no-alert
|
||||||
|
newKey.value = null;
|
||||||
|
|
||||||
|
await del('/me/keys');
|
||||||
|
|
||||||
|
keys.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey(event) {
|
||||||
|
event.target.select();
|
||||||
|
navigator.clipboard.writeText(newKey.value.key);
|
||||||
|
|
||||||
|
events.emit('feedback', {
|
||||||
|
type: 'success',
|
||||||
|
message: 'Key copied to clipboard',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-actions,
|
||||||
|
.key-actions {
|
||||||
|
.icon {
|
||||||
|
height: 100%;
|
||||||
|
fill: var(--glass);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
fill: var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(25rem, 1fr));
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
background: var(--background);
|
||||||
|
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: .9rem;
|
||||||
|
height: .9rem;
|
||||||
|
fill: var(--glass-strong-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-header .key-value {
|
||||||
|
padding: .5rem .5rem .25rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-details .key-value {
|
||||||
|
padding: .25rem .5rem .5rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-identifier {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-actions .icon {
|
||||||
|
padding: 0 .5rem .5rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newkey {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--enabled-background);
|
||||||
|
border: solid 1px var(--success);
|
||||||
|
border-radius: .25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 24rem;
|
||||||
|
padding: .25rem .75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-info {
|
||||||
|
margin: 0 0 .5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headers {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: .5rem 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(--small-20) {
|
||||||
|
.keys {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { fetchUserKeys } from '#/src/auth.js';
|
||||||
|
|
||||||
|
export async function onBeforeRender(pageContext) {
|
||||||
|
const keys = await fetchUserKeys(pageContext.user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageContext: {
|
||||||
|
title: 'API keys',
|
||||||
|
pageProps: {
|
||||||
|
keys,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -26,7 +26,7 @@ export function curateActor(actor, context = {}) {
|
||||||
cup: actor.cup,
|
cup: actor.cup,
|
||||||
waist: actor.waist,
|
waist: actor.waist,
|
||||||
hip: actor.hip,
|
hip: actor.hip,
|
||||||
naturalBoobs: actor.naturalBoobs,
|
naturalBoobs: actor.natural_boobs,
|
||||||
height: actor.height && {
|
height: actor.height && {
|
||||||
metric: actor.height,
|
metric: actor.height,
|
||||||
imperial: unit(actor.height, 'cm').splitUnit(['ft', 'in']).map((value) => Math.round(value.toNumber())),
|
imperial: unit(actor.height, 'cm').splitUnit(['ft', 'in']).map((value) => Math.round(value.toNumber())),
|
||||||
|
@ -36,7 +36,7 @@ export function curateActor(actor, context = {}) {
|
||||||
imperial: Math.round(unit(actor.weight, 'kg').toNumeric('lbs')),
|
imperial: Math.round(unit(actor.weight, 'kg').toNumeric('lbs')),
|
||||||
},
|
},
|
||||||
eyes: actor.eyes,
|
eyes: actor.eyes,
|
||||||
hairColor: actor.hairColor,
|
hairColor: actor.hair_color,
|
||||||
hasTattoos: actor.has_tattoos,
|
hasTattoos: actor.has_tattoos,
|
||||||
tattoos: actor.tattoos,
|
tattoos: actor.tattoos,
|
||||||
hasPiercings: actor.has_piercings,
|
hasPiercings: actor.has_piercings,
|
||||||
|
|
|
@ -79,7 +79,7 @@ export async function post(path, data, options = {}) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
|
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: data && JSON.stringify(data),
|
||||||
...postHeaders,
|
...postHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -95,8 +95,6 @@ export async function post(path, data, options = {}) {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(body.statusMessage);
|
|
||||||
|
|
||||||
showFeedback(false, options, body.statusMessage);
|
showFeedback(false, options, body.statusMessage);
|
||||||
throw new Error(body.statusMessage);
|
throw new Error(body.statusMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -109,7 +107,7 @@ export async function patch(path, data, options = {}) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
|
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(data),
|
body: data && JSON.stringify(data),
|
||||||
...postHeaders,
|
...postHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -136,7 +134,7 @@ export async function del(path, options = {}) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
|
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
body: JSON.stringify(options.data),
|
body: options.data && JSON.stringify(options.data),
|
||||||
...postHeaders,
|
...postHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
103
src/auth.js
103
src/auth.js
|
@ -4,13 +4,17 @@ import crypto from 'crypto';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { createAvatar } from '@dicebear/core';
|
import { createAvatar } from '@dicebear/core';
|
||||||
import { shapes } from '@dicebear/collection';
|
import { shapes } from '@dicebear/collection';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
|
||||||
import { knexOwner as knex } from './knex.js';
|
import { knexOwner as knex } from './knex.js';
|
||||||
|
import redis from './redis.js';
|
||||||
import { curateUser, fetchUser } from './users.js';
|
import { curateUser, fetchUser } from './users.js';
|
||||||
import { HttpError } from './errors.js';
|
import { HttpError } from './errors.js';
|
||||||
import initLogger from './logger.js';
|
import slugify from '../utils/slugify.js';
|
||||||
|
import initLogger, { initAccessLogger } from './logger.js';
|
||||||
|
|
||||||
const logger = initLogger();
|
const logger = initLogger();
|
||||||
|
const accessLogger = initAccessLogger();
|
||||||
const scrypt = util.promisify(crypto.scrypt);
|
const scrypt = util.promisify(crypto.scrypt);
|
||||||
|
|
||||||
async function verifyPassword(password, storedPassword) {
|
async function verifyPassword(password, storedPassword) {
|
||||||
|
@ -138,3 +142,100 @@ export async function signup(credentials, userIp) {
|
||||||
|
|
||||||
return fetchUser(userId);
|
return fetchUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function curateKey(key) {
|
||||||
|
return {
|
||||||
|
id: key.id,
|
||||||
|
identifier: key.identifier,
|
||||||
|
lastUsedAt: key.last_used_at,
|
||||||
|
lastUsedIp: key.last_used_ip,
|
||||||
|
createdAt: key.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserKeys(reqUser) {
|
||||||
|
const keys = await knex('users_keys')
|
||||||
|
.where('user_id', reqUser.id)
|
||||||
|
.orderBy('created_at', 'asc');
|
||||||
|
|
||||||
|
return keys.map((key) => curateKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyKey(userId, key, req) {
|
||||||
|
if (!key || !userId) {
|
||||||
|
throw new HttpError('The API credentials are not provided.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key
|
||||||
|
|
||||||
|
const storedKey = await knex('users_keys')
|
||||||
|
.where('user_id', userId)
|
||||||
|
.where('key', hashedKey)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!storedKey) {
|
||||||
|
throw new HttpError('The API credentials are invalid.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
accessLogger.access({
|
||||||
|
userId,
|
||||||
|
identifier: storedKey.identifier,
|
||||||
|
ip: req.userIp,
|
||||||
|
});
|
||||||
|
|
||||||
|
knex('users_keys')
|
||||||
|
.where('id', storedKey.id)
|
||||||
|
.update('last_used_at', knex.raw('now()'))
|
||||||
|
.update('last_used_ip', req.userIp)
|
||||||
|
.then(() => {
|
||||||
|
// no need to wait for this
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createKey(reqUser) {
|
||||||
|
const cooldownKey = `traxxx:key_create_cooldown:${reqUser.id}`;
|
||||||
|
|
||||||
|
if (reqUser.role !== 'admin' && await redis.exists(cooldownKey)) {
|
||||||
|
throw new HttpError(`You can only create a new API key once every ${config.apiAccess.keyCooldown} minutes.`, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = await fetchUserKeys(reqUser);
|
||||||
|
|
||||||
|
if (keys.length >= config.apiAccess.keyLimit) {
|
||||||
|
throw new HttpError(`You can only hold ${config.apiAccess.keyLimit} API keys at one time. Please remove a key.`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = crypto.randomBytes(config.apiAccess.keySize).toString('base64url');
|
||||||
|
const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key
|
||||||
|
|
||||||
|
const identifier = slugify([faker.word.adjective(), faker.animal[faker.animal.type()]()]);
|
||||||
|
|
||||||
|
const [newKey] = await knex('users_keys')
|
||||||
|
.insert({
|
||||||
|
user_id: reqUser.id,
|
||||||
|
key: hashedKey,
|
||||||
|
identifier,
|
||||||
|
})
|
||||||
|
.returning('*');
|
||||||
|
|
||||||
|
await redis.set(cooldownKey, identifier);
|
||||||
|
await redis.expire(cooldownKey, config.apiAccess.keyCooldown * 60);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...curateKey(newKey),
|
||||||
|
key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeUserKey(reqUser, identifier) {
|
||||||
|
await knex('users_keys')
|
||||||
|
.where('user_id', reqUser.id)
|
||||||
|
.where('identifier', identifier)
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushUserKeys(reqUser) {
|
||||||
|
await knex('users_keys')
|
||||||
|
.where('user_id', reqUser.id)
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|
|
@ -34,3 +34,20 @@ export default function initLogger(customLabel) {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initAccessLogger() {
|
||||||
|
return winston.createLogger({
|
||||||
|
level: 'access',
|
||||||
|
levels: {
|
||||||
|
access: 0,
|
||||||
|
},
|
||||||
|
format: winston.format.printf((data) => JSON.stringify({ ...data.message, timestamp: new Date() })),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.DailyRotateFile({
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
filename: path.join('log', 'access_%DATE%.log'),
|
||||||
|
level: 'access',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ export function curateUser(user, _assets = {}) {
|
||||||
emailVerified: user.email_verified,
|
emailVerified: user.email_verified,
|
||||||
identityVerified: user.identity_verified,
|
identityVerified: user.identity_verified,
|
||||||
avatar: `/media/avatars/${user.id}_${user.username}.png`,
|
avatar: `/media/avatars/${user.id}_${user.username}.png`,
|
||||||
|
role: user.role,
|
||||||
createdAt: user.created_at,
|
createdAt: user.created_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,7 @@ export const actorsSchema = `
|
||||||
query: String
|
query: String
|
||||||
limit: Int! = 30
|
limit: Int! = 30
|
||||||
page: Int! = 1
|
page: Int! = 1
|
||||||
order: [String]
|
order: [String!]
|
||||||
): ActorsResult
|
): ActorsResult
|
||||||
|
|
||||||
actor(
|
actor(
|
||||||
|
@ -54,7 +54,7 @@ export const actorsSchema = `
|
||||||
): Actor
|
): Actor
|
||||||
|
|
||||||
actorsById(
|
actorsById(
|
||||||
ids: [Int]!
|
ids: [Int!]!
|
||||||
): [Actor]
|
): [Actor]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +70,7 @@ export const actorsSchema = `
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActorsResult {
|
type ActorsResult {
|
||||||
nodes: [Actor]
|
nodes: [Actor!]!
|
||||||
total: Int
|
total: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,6 +81,8 @@ export const actorsSchema = `
|
||||||
gender: String
|
gender: String
|
||||||
dateOfBirth: Date
|
dateOfBirth: Date
|
||||||
age: Int
|
age: Int
|
||||||
|
ageFromBirth: Int
|
||||||
|
ageThen: Int
|
||||||
origin: Location
|
origin: Location
|
||||||
residence: Location
|
residence: Location
|
||||||
height: Int
|
height: Int
|
||||||
|
|
|
@ -1,7 +1,16 @@
|
||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
|
import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */
|
||||||
import IPCIDR from 'ip-cidr';
|
import IPCIDR from 'ip-cidr';
|
||||||
|
|
||||||
import { login, signup } from '../auth.js';
|
import {
|
||||||
|
login,
|
||||||
|
signup,
|
||||||
|
fetchUserKeys,
|
||||||
|
createKey,
|
||||||
|
removeUserKey,
|
||||||
|
flushUserKeys,
|
||||||
|
} from '../auth.js';
|
||||||
|
|
||||||
import { fetchUser } from '../users.js';
|
import { fetchUser } from '../users.js';
|
||||||
|
|
||||||
function getIp(req) {
|
function getIp(req) {
|
||||||
|
@ -68,4 +77,28 @@ export async function signupApi(req, res) {
|
||||||
req.session.user = user;
|
req.session.user = user;
|
||||||
res.send(user);
|
res.send(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchUserKeysApi(req, res) {
|
||||||
|
const keys = await fetchUserKeys(req.user);
|
||||||
|
|
||||||
|
res.send(stringify(keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createKeyApi(req, res) {
|
||||||
|
const key = await createKey(req.user);
|
||||||
|
|
||||||
|
res.send(stringify(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeUserKeyApi(req, res) {
|
||||||
|
await removeUserKey(req.user, req.params.keyIdentifier);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushUserKeysApi(req, res) {
|
||||||
|
await flushUserKeys(req.user);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
/* eslint-enable no-param-reassign */
|
/* eslint-enable no-param-reassign */
|
||||||
|
|
|
@ -37,7 +37,7 @@ export const entitiesSchema = `
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntitiesResult {
|
type EntitiesResult {
|
||||||
nodes: [Entity]
|
nodes: [Entity!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Entity {
|
type Entity {
|
||||||
|
@ -47,7 +47,7 @@ export const entitiesSchema = `
|
||||||
url: String
|
url: String
|
||||||
type: String
|
type: String
|
||||||
parent: Entity
|
parent: Entity
|
||||||
children: [Entity]
|
children: [Entity!]!
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import config from 'config';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -24,6 +25,8 @@ import {
|
||||||
fetchActorsByIdGraphql,
|
fetchActorsByIdGraphql,
|
||||||
} from './actors.js';
|
} from './actors.js';
|
||||||
|
|
||||||
|
import { verifyKey } from '../auth.js';
|
||||||
|
|
||||||
const schema = buildSchema(`
|
const schema = buildSchema(`
|
||||||
type Query {
|
type Query {
|
||||||
movies(
|
movies(
|
||||||
|
@ -61,6 +64,13 @@ const DateScalar = new GraphQLScalarType({
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function graphqlApi(req, res) {
|
export async function graphqlApi(req, res) {
|
||||||
|
if (!config.apiAccess.graphqlEnabled) {
|
||||||
|
res.status(404).send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await verifyKey(req.headers['api-user'], req.headers['api-key'], req);
|
||||||
|
|
||||||
const data = await graphql({
|
const data = await graphql({
|
||||||
schema,
|
schema,
|
||||||
source: req.body.query,
|
source: req.body.query,
|
||||||
|
|
|
@ -75,9 +75,9 @@ export const scenesSchema = `
|
||||||
scenes(
|
scenes(
|
||||||
query: String
|
query: String
|
||||||
scope: String
|
scope: String
|
||||||
entities: [String]
|
entities: [String!]
|
||||||
actorIds: [String]
|
actorIds: [String!]
|
||||||
tags: [String]
|
tags: [String!]
|
||||||
limit: Int! = 30
|
limit: Int! = 30
|
||||||
page: Int! = 1
|
page: Int! = 1
|
||||||
): ReleasesResult
|
): ReleasesResult
|
||||||
|
@ -87,16 +87,16 @@ export const scenesSchema = `
|
||||||
): Release
|
): Release
|
||||||
|
|
||||||
scenesById(
|
scenesById(
|
||||||
ids: [Int]!
|
ids: [Int!]!
|
||||||
): [Release]
|
): [Release]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReleasesAggregate {
|
type ReleasesAggregate {
|
||||||
actors: [Actor]
|
actors: [Actor!]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReleasesResult {
|
type ReleasesResult {
|
||||||
nodes: [Release]
|
nodes: [Release!]!
|
||||||
total: Int
|
total: Int
|
||||||
aggregates: ReleasesAggregate
|
aggregates: ReleasesAggregate
|
||||||
}
|
}
|
||||||
|
@ -112,13 +112,13 @@ export const scenesSchema = `
|
||||||
shootId: Int
|
shootId: Int
|
||||||
channel: Entity
|
channel: Entity
|
||||||
network: Entity
|
network: Entity
|
||||||
actors: [Actor]
|
actors: [Actor!]!
|
||||||
tags: [Tag]
|
tags: [Tag!]!
|
||||||
poster: Media
|
poster: Media
|
||||||
trailer: Media
|
trailer: Media
|
||||||
photos: [Media]
|
photos: [Media!]!
|
||||||
covers: [Media]
|
covers: [Media!]!
|
||||||
movies: [Release]
|
movies: [Release!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tag {
|
type Tag {
|
||||||
|
|
|
@ -28,6 +28,10 @@ import {
|
||||||
loginApi,
|
loginApi,
|
||||||
logoutApi,
|
logoutApi,
|
||||||
signupApi,
|
signupApi,
|
||||||
|
fetchUserKeysApi,
|
||||||
|
createKeyApi,
|
||||||
|
removeUserKeyApi,
|
||||||
|
flushUserKeysApi,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -162,6 +166,12 @@ export default async function initServer() {
|
||||||
router.post('/api/templates', createTemplateApi);
|
router.post('/api/templates', createTemplateApi);
|
||||||
router.delete('/api/templates/:templateId', removeTemplateApi);
|
router.delete('/api/templates/:templateId', removeTemplateApi);
|
||||||
|
|
||||||
|
// API KEYS
|
||||||
|
router.get('/api/me/keys', fetchUserKeysApi);
|
||||||
|
router.post('/api/keys', createKeyApi);
|
||||||
|
router.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
|
||||||
|
router.delete('/api/me/keys', flushUserKeysApi);
|
||||||
|
|
||||||
// ALERTS
|
// ALERTS
|
||||||
router.get('/api/alerts', fetchAlertsApi);
|
router.get('/api/alerts', fetchAlertsApi);
|
||||||
router.post('/api/alerts', createAlertApi);
|
router.post('/api/alerts', createAlertApi);
|
||||||
|
@ -182,7 +192,10 @@ export default async function initServer() {
|
||||||
// TAGS
|
// TAGS
|
||||||
router.get('/api/tags', fetchTagsApi);
|
router.get('/api/tags', fetchTagsApi);
|
||||||
|
|
||||||
router.post('/graphql', graphqlApi);
|
if (config.apiAccess.graphqlEnabled) {
|
||||||
|
router.post('/graphql', graphqlApi);
|
||||||
|
}
|
||||||
|
|
||||||
router.use(consentHandler);
|
router.use(consentHandler);
|
||||||
|
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
|
|
Loading…
Reference in New Issue