1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-11 18:24:43 +02:00

Desktop, Clipper: Web Clipper now must request authorisation before accessing the application data

This commit is contained in:
Laurent Cozic 2021-06-22 19:57:04 +01:00
parent 638b3236cf
commit 67d9977489
19 changed files with 403 additions and 82 deletions

View File

@ -809,6 +809,9 @@ packages/lib/BaseModel.js.map
packages/lib/BaseSyncTarget.d.ts
packages/lib/BaseSyncTarget.js
packages/lib/BaseSyncTarget.js.map
packages/lib/ClipperServer.d.ts
packages/lib/ClipperServer.js
packages/lib/ClipperServer.js.map
packages/lib/HtmlToMd.d.ts
packages/lib/HtmlToMd.js
packages/lib/HtmlToMd.js.map
@ -1331,6 +1334,9 @@ packages/lib/services/rest/ApiResponse.js.map
packages/lib/services/rest/actionApi.desktop.d.ts
packages/lib/services/rest/actionApi.desktop.js
packages/lib/services/rest/actionApi.desktop.js.map
packages/lib/services/rest/routes/auth.d.ts
packages/lib/services/rest/routes/auth.js
packages/lib/services/rest/routes/auth.js.map
packages/lib/services/rest/routes/folders.d.ts
packages/lib/services/rest/routes/folders.js
packages/lib/services/rest/routes/folders.js.map

6
.gitignore vendored
View File

@ -795,6 +795,9 @@ packages/lib/BaseModel.js.map
packages/lib/BaseSyncTarget.d.ts
packages/lib/BaseSyncTarget.js
packages/lib/BaseSyncTarget.js.map
packages/lib/ClipperServer.d.ts
packages/lib/ClipperServer.js
packages/lib/ClipperServer.js.map
packages/lib/HtmlToMd.d.ts
packages/lib/HtmlToMd.js
packages/lib/HtmlToMd.js.map
@ -1317,6 +1320,9 @@ packages/lib/services/rest/ApiResponse.js.map
packages/lib/services/rest/actionApi.desktop.d.ts
packages/lib/services/rest/actionApi.desktop.js
packages/lib/services/rest/actionApi.desktop.js.map
packages/lib/services/rest/routes/auth.d.ts
packages/lib/services/rest/routes/auth.js
packages/lib/services/rest/routes/auth.js.map
packages/lib/services/rest/routes/folders.d.ts
packages/lib/services/rest/routes/folders.js
packages/lib/services/rest/routes/folders.js.map

View File

@ -82,6 +82,8 @@ class Command extends BaseCommand {
lines.push('');
lines.push('In the documentation below, the token will not be specified every time however you will need to include it.');
lines.push('');
lines.push('If needed you may also [request the token programmatically](https://github.com/laurent22/joplin/blob/dev/readme/spec/clipper_auth.md)');
lines.push('');
lines.push('# Using the API');
lines.push('');
@ -114,6 +116,7 @@ class Command extends BaseCommand {
lines.push('\tcurl http://localhost:41184/tags?fields=id');
lines.push('');
lines.push('By default API results will contain the following fields: **id**, **parent_id**, **title**');
lines.push('');
lines.push('# Pagination');
lines.push('');

View File

@ -17,7 +17,7 @@ class Command extends BaseCommand {
async action(args) {
const command = args.command;
const ClipperServer = require('@joplin/lib/ClipperServer');
const ClipperServer = require('@joplin/lib/ClipperServer').default;
ClipperServer.instance().initialize();
const stdoutFn = (...s) => this.stdout(s.join(' '));
const clipperLogger = new Logger();

View File

@ -106,7 +106,7 @@ browser_.runtime.onMessage.addListener(async (command) => {
newArea.height *= ratio;
content.crop_rect = newArea;
fetch(`${command.api_base_url}/notes`, {
fetch(`${command.api_base_url}/notes?token=${encodeURIComponent(command.token)}`, {
method: 'POST',
headers: {
'Accept': 'application/json',

View File

@ -521,6 +521,7 @@
name: 'screenshotArea',
content: content,
api_base_url: command.api_base_url,
token: command.token,
});
}, 100);
};

View File

@ -2,6 +2,7 @@ body {
margin: 0;
padding: 0;
font-family: sans-serif;
line-height: 2em;
}
.App {
@ -13,11 +14,19 @@ body {
flex-direction: column;
background-color: #162b3d;
font-size: 16px;
color: #5A95C7;
color: #c1e2ff;
padding: 10px;
box-sizing: border-box;
}
.App.Startup {
padding: 20px;
}
.App.Startup h1 {
font-size: 20px;
}
.App h2 {
font-size: 1em;
margin-top: .5em;
@ -26,7 +35,7 @@ body {
}
.App a {
color: #5A95C7;
color: #c1e2ff;
}
.App .Disabled {
@ -139,7 +148,7 @@ body {
}
.App .StatusBar {
color: #5A95C7;
color: #c1e2ff;
font-size: .7em;
display: flex;
flex: 0;

View File

@ -50,17 +50,6 @@ class PreviewComponent extends React.PureComponent {
<a className={'Confirm Button'} href="#" onClick={this.props.onConfirmClick}>Confirm</a>
</div>
);
// return (
// <div className="Preview">
// <a className={"Confirm Button"} onClick={this.props.onConfirmClick}>Confirm</a>
// <h2>Preview:</h2>
// <input className={"Title"} value={this.props.title} onChange={this.props.onTitleChange}/>
// <div className={"BodyWrapper"}>
// <div className={"Body"} ref={this.bodyRef} dangerouslySetInnerHTML={{__html: this.props.body_html}}></div>
// </div>
// </div>
// );
}
}
@ -131,6 +120,7 @@ class AppComponent extends Component {
api_base_url: baseUrl,
parent_id: this.props.selectedFolderId,
tags: this.state.selectedTags.join(','),
token: bridge().token(),
});
window.close();
@ -194,6 +184,8 @@ class AppComponent extends Component {
}
async componentDidMount() {
bridge().onReactAppStarts();
try {
await this.loadContentScripts();
} catch (error) {
@ -242,7 +234,46 @@ class AppComponent extends Component {
}
}
renderStartupScreen() {
const messages = {
serverFoundState: {
'searching': 'Connecting to the Joplin application...',
'not_found': 'Error: Could not connect to the Joplin application. Please ensure that it is started and that the clipper service is enabled in the configuration.',
},
authState: {
'starting': 'Starting...',
'waiting': 'The Joplin Web Clipper requires your authorisation in order to access your data. To do, please open the Joplin desktop application and grant permission. Note: Joplin 2.1+ is needed to use this version of the Web Clipper.',
'rejected': 'Permission to access your data was not granted. To try again please close this popup and open it again.',
},
};
const foundState = this.props.clipperServer.foundState;
let msg = '';
let title = '';
if (messages.serverFoundState[foundState]) {
msg = messages.serverFoundState[foundState];
} else {
msg = messages.authState[this.props.authStatus];
title = <h1>{'Permission needed'}</h1>;
}
if (!msg) throw new Error(`Invalidate state: ${foundState} / ${this.props.authStatus}`);
return (
<div className="App Startup">
{title}
{msg}
</div>
);
}
render() {
if (this.props.authStatus !== 'accepted') {
return this.renderStartupScreen();
}
if (!this.state.contentScriptLoaded) {
let msg = 'Loading...';
if (this.state.contentScriptError) msg = `The Joplin extension is not available on this tab due to: ${this.state.contentScriptError}`;
@ -417,6 +448,7 @@ const mapStateToProps = (state) => {
tags: state.tags,
selectedFolderId: state.selectedFolderId,
isProbablyReaderable: state.isProbablyReaderable,
authStatus: state.authStatus,
};
};

View File

@ -1,9 +1,18 @@
const { randomClipperPort } = require('./randomClipperPort');
function msleep(ms) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
}
class Bridge {
constructor() {
this.nounce_ = Date.now();
this.token_ = null;
}
async init(browser, browserSupportsPromises, store) {
@ -77,7 +86,82 @@ class Bridge {
env: this.env(),
});
this.findClipperServerPort();
await this.findClipperServerPort();
if (this.clipperServerPortStatus_ !== 'found') {
console.info('Skipping initialisation because server port was not found');
return;
}
await this.restoreState();
}
token() {
return this.token_;
}
async onReactAppStarts() {
await this.checkAuth();
if (!this.token_) return; // Didn't get a token
const folders = await this.folderTree();
this.dispatch({ type: 'FOLDERS_SET', folders: folders.items ? folders.items : folders });
let tags = [];
for (let page = 1; page < 10000; page++) {
const result = await this.clipperApiExec('GET', 'tags', { page: page, order_by: 'title', order_dir: 'ASC' });
const resultTags = result.items ? result.items : result;
const hasMore = ('has_more' in result) && result.has_more;
tags = tags.concat(resultTags);
if (!hasMore) break;
}
this.dispatch({ type: 'TAGS_SET', tags: tags });
}
async checkAuth() {
this.dispatch({ type: 'AUTH_STATE_SET', value: 'starting' });
const existingToken = await this.storageGet(['token']);
this.token_ = existingToken.token;
const authCheckResponse = await this.clipperApiExec('GET', 'auth/check', { token: this.token_ });
if (authCheckResponse.valid) {
console.info('checkAuth: we already have a valid token - exiting');
this.dispatch({ type: 'AUTH_STATE_SET', value: 'accepted' });
return;
}
this.token_ = null;
await this.storageSet({ token: this.token_ });
this.dispatch({ type: 'AUTH_STATE_SET', value: 'waiting' });
const response = await this.clipperApiExec('POST', 'auth');
const authToken = response.auth_token;
console.info('checkAuth: we do not have a token - requesting one using auth_token: ', authToken);
while (true) {
const response = await this.clipperApiExec('GET', 'auth/check', { auth_token: authToken });
if (response.status === 'rejected') {
console.info('checkAuth: Auth request was not accepted', response);
this.dispatch({ type: 'AUTH_STATE_SET', value: 'rejected' });
break;
} else if (response.status === 'accepted') {
console.info('checkAuth: Auth request was accepted', response);
this.dispatch({ type: 'AUTH_STATE_SET', value: 'accepted' });
this.token_ = response.token;
await this.storageSet({ token: this.token_ });
break;
} else if (response.status === 'waiting') {
await msleep(1000);
} else {
throw new Error(`Unknown auth/check status: ${response.status}`);
}
}
}
async backgroundPage(browser) {
@ -146,22 +230,6 @@ class Bridge {
this.clipperServerPortStatus_ = 'found';
this.clipperServerPort_ = state.port;
this.dispatch({ type: 'CLIPPER_SERVER_SET', foundState: 'found', port: state.port });
const folders = await this.folderTree();
this.dispatch({ type: 'FOLDERS_SET', folders: folders.items ? folders.items : folders });
let tags = [];
for (let page = 1; page < 10000; page++) {
const result = await this.clipperApiExec('GET', 'tags', { page: page, order_by: 'title', order_dir: 'ASC' });
const resultTags = result.items ? result.items : result;
const hasMore = ('has_more' in result) && result.has_more;
tags = tags.concat(resultTags);
if (!hasMore) break;
}
this.dispatch({ type: 'TAGS_SET', tags: tags });
bridge().restoreState();
return;
}
} catch (error) {
@ -311,6 +379,8 @@ class Bridge {
if (body) fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
query = Object.assign(query || {}, { token: this.token_ });
let queryString = '';
if (query) {
const s = [];

View File

@ -19,6 +19,7 @@ const defaultState = {
selectedFolderId: null,
env: 'prod',
isProbablyReaderable: true,
authStatus: 'starting',
};
const reduxMiddleware = store => next => async (action) => {
@ -94,6 +95,11 @@ function reducer(state = defaultState, action) {
newState = Object.assign({}, state);
newState.env = action.env;
} else if (action.type === 'AUTH_STATE_SET') {
newState = Object.assign({}, state);
newState.authStatus = action.value;
}
return newState;

View File

@ -35,7 +35,7 @@ import Tag from '@joplin/lib/models/Tag';
import { reg } from '@joplin/lib/registry';
const packageInfo = require('./packageInfo.js');
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
const ClipperServer = require('@joplin/lib/ClipperServer');
import ClipperServer from '@joplin/lib/ClipperServer';
const { webFrame } = require('electron');
const Menu = bridge().Menu;
const PluginManager = require('@joplin/lib/services/PluginManager');
@ -757,7 +757,7 @@ class Application extends BaseApplication {
ClipperServer.instance().setDispatch(this.store().dispatch);
if (Setting.value('clipperServer.autoStart')) {
ClipperServer.instance().start();
void ClipperServer.instance().start();
}
ExternalEditWatcher.instance().setLogger(reg.logger());

View File

@ -3,7 +3,7 @@ const { connect } = require('react-redux');
const bridge = require('electron').remote.require('./bridge').default;
const { themeStyle } = require('@joplin/lib/theme');
const { _ } = require('@joplin/lib/locale');
const ClipperServer = require('@joplin/lib/ClipperServer');
const ClipperServer = require('@joplin/lib/ClipperServer').default;
const Setting = require('@joplin/lib/models/Setting').default;
const { clipboard } = require('electron');
const ExtensionBadge = require('./ExtensionBadge.min');
@ -44,7 +44,7 @@ class ClipperConfigScreenComponent extends React.Component {
if (confirm(_('Are you sure you want to renew the authorisation token?'))) {
void EncryptionService.instance()
.generateApiToken()
.then((token: string) => {
.then((token) => {
Setting.setValue('api.token', token);
});
}

View File

@ -35,6 +35,7 @@ import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import ShareService from '@joplin/lib/services/share/ShareService';
import { reg } from '@joplin/lib/registry';
import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems';
import ClipperServer from '@joplin/lib/ClipperServer';
const { connect } = require('react-redux');
const { PromptDialog } = require('../PromptDialog.min.js');
@ -70,6 +71,7 @@ interface Props {
startupPluginsLoaded: boolean;
shareInvitations: ShareInvitation[];
isSafeMode: boolean;
needApiAuth: boolean;
}
interface ShareFolderDialogOptions {
@ -568,9 +570,24 @@ class MainScreenComponent extends React.Component<Props, State> {
void reg.scheduleSync(1000);
};
const onApiGrantAuthorization = (accept: boolean) => {
ClipperServer.instance().api.acceptAuthToken(accept);
};
let msg = null;
if (this.props.isSafeMode) {
// When adding something here, don't forget to update the condition in
// this.messageBoxVisible()
if (this.props.needApiAuth) {
msg = this.renderNotificationMessage(
_('The Web Clipper needs your authorisation to access your data.'),
_('Grant authorisation'),
() => onApiGrantAuthorization(true),
_('Reject'),
() => onApiGrantAuthorization(false)
);
} else if (this.props.isSafeMode) {
msg = this.renderNotificationMessage(
_('Safe mode is currently active. Note rendering and all plugins are temporarily disabled.'),
_('Disable safe mode and restart'),
@ -634,7 +651,7 @@ class MainScreenComponent extends React.Component<Props, State> {
messageBoxVisible(props: Props = null) {
if (!props) props = this.props;
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification(props);
return props.hasDisabledSyncItems || props.showMissingMasterKeyMessage || props.showNeedUpgradingMasterKeyMessage || props.showShouldReencryptMessage || props.hasDisabledEncryptionItems || this.props.shouldUpgradeSyncTarget || props.isSafeMode || this.showShareInvitationNotification(props) || this.props.needApiAuth;
}
registerCommands() {
@ -860,6 +877,7 @@ const mapStateToProps = (state: AppState) => {
startupPluginsLoaded: state.startupPluginsLoaded,
shareInvitations: state.shareService.shareInvitations,
isSafeMode: state.settings.isSafeMode,
needApiAuth: state.needApiAuth,
};
};

View File

@ -1,18 +1,31 @@
import Setting from './models/Setting';
import Logger from './Logger';
import Api, { RequestFile } from './services/rest/Api';
import ApiResponse from './services/rest/ApiResponse';
const urlParser = require('url');
const Setting = require('./models/Setting').default;
const Logger = require('./Logger').default;
const { randomClipperPort, startPort } = require('./randomClipperPort');
const enableServerDestroy = require('server-destroy');
const Api = require('./services/rest/Api').default;
const ApiResponse = require('./services/rest/ApiResponse').default;
const multiparty = require('multiparty');
class ClipperServer {
export enum StartState {
Idle = 'idle',
Starting = 'starting',
Started = 'started',
}
export default class ClipperServer {
private logger_: Logger;
private startState_: StartState = StartState.Idle;
private server_: any = null;
private port_: number = null;
private api_: Api = null;
private dispatch_: Function;
private static instance_: ClipperServer = null;
constructor() {
this.logger_ = new Logger();
this.startState_ = 'idle';
this.server_ = null;
this.port_ = null;
}
static instance() {
@ -21,13 +34,17 @@ class ClipperServer {
return this.instance_;
}
initialize(actionApi = null) {
this.api_ = new Api(() => {
return Setting.value('api.token');
}, actionApi);
public get api(): Api {
return this.api_;
}
setLogger(l) {
initialize(actionApi: any = null) {
this.api_ = new Api(() => {
return Setting.value('api.token');
}, (action: any) => { this.dispatch(action); }, actionApi);
}
setLogger(l: Logger) {
this.logger_ = l;
}
@ -35,16 +52,16 @@ class ClipperServer {
return this.logger_;
}
setDispatch(d) {
setDispatch(d: Function) {
this.dispatch_ = d;
}
dispatch(action) {
dispatch(action: any) {
if (!this.dispatch_) throw new Error('dispatch not set!');
this.dispatch_(action);
}
setStartState(v) {
setStartState(v: StartState) {
if (this.startState_ === v) return;
this.startState_ = v;
this.dispatch({
@ -53,7 +70,7 @@ class ClipperServer {
});
}
setPort(v) {
setPort(v: number) {
if (this.port_ === v) return;
this.port_ = v;
this.dispatch({
@ -85,7 +102,7 @@ class ClipperServer {
async start() {
this.setPort(null);
this.setStartState('starting');
this.setStartState(StartState.Starting);
const settingPort = Setting.value('api.port');
@ -93,15 +110,15 @@ class ClipperServer {
const p = settingPort ? settingPort : await this.findAvailablePort();
this.setPort(p);
} catch (error) {
this.setStartState('idle');
this.setStartState(StartState.Idle);
this.logger().error(error);
return;
}
this.server_ = require('http').createServer();
this.server_.on('request', async (request, response) => {
const writeCorsHeaders = (code, contentType = 'application/json', additionalHeaders = null) => {
this.server_.on('request', async (request: any, response: any) => {
const writeCorsHeaders = (code: any, contentType = 'application/json', additionalHeaders: any = null) => {
const headers = Object.assign(
{},
{
@ -115,19 +132,19 @@ class ClipperServer {
response.writeHead(code, headers);
};
const writeResponseJson = (code, object) => {
const writeResponseJson = (code: any, object: any) => {
writeCorsHeaders(code);
response.write(JSON.stringify(object));
response.end();
};
const writeResponseText = (code, text) => {
const writeResponseText = (code: any, text: any) => {
writeCorsHeaders(code, 'text/plain');
response.write(text);
response.end();
};
const writeResponseInstance = (code, instance) => {
const writeResponseInstance = (code: any, instance: any) => {
if (instance.type === 'attachment') {
const filename = instance.attachmentFilename ? instance.attachmentFilename : 'file';
writeCorsHeaders(code, instance.contentType ? instance.contentType : 'application/octet-stream', {
@ -140,7 +157,7 @@ class ClipperServer {
}
};
const writeResponse = (code, response) => {
const writeResponse = (code: any, response: any) => {
if (response instanceof ApiResponse) {
writeResponseInstance(code, response);
} else if (typeof response === 'string') {
@ -156,7 +173,7 @@ class ClipperServer {
const url = urlParser.parse(request.url, true);
const execRequest = async (request, body = '', files = []) => {
const execRequest = async (request: any, body = '', files: RequestFile[] = []) => {
try {
const response = await this.api_.route(request.method, url.pathname, url.query, body, files);
writeResponse(200, response);
@ -181,27 +198,27 @@ class ClipperServer {
if (contentType.indexOf('multipart/form-data') === 0) {
const form = new multiparty.Form();
form.parse(request, function(error, fields, files) {
form.parse(request, function(error: any, fields: any, files: any) {
if (error) {
writeResponse(error.httpCode ? error.httpCode : 500, error.message);
return;
} else {
execRequest(request, fields && fields.props && fields.props.length ? fields.props[0] : '', files && files.data ? files.data : []);
void execRequest(request, fields && fields.props && fields.props.length ? fields.props[0] : '', files && files.data ? files.data : []);
}
});
} else {
if (request.method === 'POST' || request.method === 'PUT') {
let body = '';
request.on('data', data => {
request.on('data', (data: any) => {
body += data;
});
request.on('end', async () => {
execRequest(request, body);
void execRequest(request, body);
});
} else {
execRequest(request);
void execRequest(request);
}
}
}
@ -213,7 +230,7 @@ class ClipperServer {
this.server_.listen(this.port_, '127.0.0.1');
this.setStartState('started');
this.setStartState(StartState.Started);
// We return an empty promise that never resolves so that it's possible to `await` the server indefinitely.
// This is used only in command-server.js
@ -223,9 +240,7 @@ class ClipperServer {
async stop() {
this.server_.destroy();
this.server_ = null;
this.setStartState('idle');
this.setStartState(StartState.Idle);
this.setPort(null);
}
}
module.exports = ClipperServer;

View File

@ -91,6 +91,7 @@ export interface State {
editorNoteStatuses: any;
isInsertingNotes: boolean;
hasEncryptedItems: boolean;
needApiAuth: boolean;
// Extra reducer keys go here:
pluginService: PluginServiceState;
@ -160,6 +161,7 @@ export const defaultState: State = {
editorNoteStatuses: {},
isInsertingNotes: false,
hasEncryptedItems: false,
needApiAuth: false,
pluginService: pluginServiceDefaultState,
shareService: shareServiceDefaultState,
@ -1137,6 +1139,11 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
draft.pluginsLegacy = newPluginsLegacy;
}
break;
case 'API_NEED_AUTH_SET':
draft.needApiAuth = action.value;
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;

View File

@ -8,6 +8,7 @@ import route_tags from './routes/tags';
import route_master_keys from './routes/master_keys';
import route_search from './routes/search';
import route_ping from './routes/ping';
import route_auth from './routes/auth';
const { ltrimSlashes } = require('../../path-utils');
const md5 = require('md5');
@ -19,7 +20,7 @@ export enum RequestMethod {
DELETE = 'DELETE',
}
interface RequestFile {
export interface RequestFile {
path: string;
}
@ -39,6 +40,9 @@ interface RequestQuery {
limit?: number;
order_dir?: PaginationOrderDir;
order_by?: string;
// Auth token
auth_token?: string;
}
export interface Request {
@ -53,7 +57,24 @@ export interface Request {
action?: any;
}
type RouteFunction = (request: Request, id: string, link: string)=> Promise<any | void>;
export enum AuthTokenStatus {
Waiting = 'waiting',
Accepted = 'accepted',
Rejected = 'rejected',
}
interface AuthToken {
value: string;
status: AuthTokenStatus;
}
export interface RequestContext {
dispatch: Function;
authToken: AuthToken;
token: string;
}
type RouteFunction = (request: Request, id: string, link: string, context: RequestContext)=> Promise<any | void>;
interface ResourceNameToRoute {
[key: string]: RouteFunction;
@ -62,13 +83,16 @@ interface ResourceNameToRoute {
export default class Api {
private token_: string | Function;
private authToken_: AuthToken = null;
private knownNounces_: any = {};
private actionApi_: any;
private resourceNameToRoute_: ResourceNameToRoute = {};
private dispatch_: Function;
public constructor(token: string = null, actionApi: any = null) {
public constructor(token: string | Function = null, dispatch: Function = null, actionApi: any = null) {
this.token_ = token;
this.actionApi_ = actionApi;
this.dispatch_ = dispatch;
this.resourceNameToRoute_ = {
ping: route_ping,
@ -79,13 +103,43 @@ export default class Api {
master_keys: route_master_keys,
search: route_search,
services: this.action_services.bind(this),
auth: route_auth,
};
this.dispatch = this.dispatch.bind(this);
}
public get token() {
public get token(): string {
return typeof this.token_ === 'function' ? this.token_() : this.token_;
}
private dispatch(action: any) {
if (action.type === 'API_AUTH_TOKEN_SET') {
this.authToken_ = {
value: action.value,
status: AuthTokenStatus.Waiting,
};
this.dispatch_({
type: 'API_NEED_AUTH_SET',
value: true,
});
return;
}
return this.dispatch_(action);
}
public acceptAuthToken(accept: boolean) {
this.authToken_.status = accept ? AuthTokenStatus.Accepted : AuthTokenStatus.Rejected;
this.dispatch_({
type: 'API_NEED_AUTH_SET',
value: false,
});
}
private parsePath(path: string) {
path = ltrimSlashes(path);
if (!path) return { fn: null, params: [] };
@ -155,8 +209,14 @@ export default class Api {
this.checkToken_(request);
const context: RequestContext = {
dispatch: this.dispatch,
token: this.token,
authToken: this.authToken_,
};
try {
return await parsedPath.fn(request, id, link);
return await parsedPath.fn(request, id, link, context);
} catch (error) {
if (!error.httpCode) error.httpCode = 500;
throw error;
@ -166,13 +226,23 @@ export default class Api {
private checkToken_(request: Request) {
// For now, whitelist some calls to allow the web clipper to work
// without an extra auth step
const whiteList = [['GET', 'ping'], ['GET', 'tags'], ['GET', 'folders'], ['POST', 'notes']];
// const whiteList = [['GET', 'ping'], ['GET', 'tags'], ['GET', 'folders'], ['POST', 'notes']];
const whiteList = [
['GET', 'ping'],
['GET', 'auth'],
['POST', 'auth'],
['GET', 'auth/check'],
];
for (let i = 0; i < whiteList.length; i++) {
if (whiteList[i][0] === request.method && whiteList[i][1] === request.path) return;
}
// If the API has been initialized without a token, it means no auth is
// needed. This is for example when it is used as the plugin data API.
if (!this.token) return;
if (!request.query || !request.query.token) throw new ErrorForbidden('Missing "token" parameter');
if (request.query.token !== this.token) throw new ErrorForbidden('Invalid "token" parameter');
}

View File

@ -0,0 +1,53 @@
import { AuthTokenStatus, Request, RequestContext } from '../Api';
import uuid from '../../../uuid';
let authToken: string = null;
export default async function(request: Request, id: string = null, _link: string = null, context: RequestContext = null) {
if (request.method === 'POST') {
authToken = uuid.createNano();
context.dispatch({
type: 'API_AUTH_TOKEN_SET',
value: authToken,
});
return { auth_token: authToken };
}
if (request.method === 'GET') {
if (id === 'check') {
if ('auth_token' in request.query) {
if (request.query.auth_token === context.authToken.value) {
const output: any = {
status: context.authToken.status,
};
if (context.authToken.status === AuthTokenStatus.Accepted) {
output.token = context.token;
}
return output;
} else {
throw new Error(`Invalid auth token: ${request.query.auth_token}`);
}
}
if ('token' in request.query) {
const isValid = request.query.token === context.token;
if (isValid) {
context.dispatch({
type: 'API_AUTH_LOGIN',
value: true,
});
}
return { valid: isValid };
}
}
}
throw new Error('Invalid request');
}

View File

@ -9,10 +9,10 @@ const { customAlphabet } = require('nanoid/non-secure');
const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 22);
export default {
create: function() {
create: function(): string {
return createUuidV4().replace(/-/g, '');
},
createNano: function() {
createNano: function(): string {
return nanoid();
},
};

View File

@ -0,0 +1,25 @@
# Clipper authorisation mechanism
In order to access the clipper API, the client must use a token, which is a random string generated by the application.
There are two ways for applications to obtain this token:
## Get it from the user
The user can copy the token in the Clipper configuration page and provide it directly to the application. This is the simplest method.
## Request it programmatically
The token can also be requested programmatically, as is done for the web clipper extension. It works as below:
- The client calls `POST /auth`. The server responds with `{ auth_token: "AUTH_TOKEN" }`. This `auth_token` is different from the regular token - it is just used to authentify the client.
- The application displays a message asking the user to Accept or Reject the access request.
- The clients calls `GET /auth/check?auth_token=AUTH_TOKEN` at regular intervals. Initially the server responds with `{ status: "waiting" }`, since we are waiting for the user to confirm or reject.
- Once the user accepts the request in the application, the server returns `{ status: "accepted", token: "API_TOKEN" }`.
- The client can now use this `API_TOKEN` to make requests.
- If the users rejects the request, the server returns `{ status: "rejected" }`, and the client can display an error message.