feat(backend): accept OAuth bearer token (#11052)

* feat(backend): accept OAuth bearer token

* refactor

* Update packages/backend/src/server/api/ApiCallService.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* Update packages/backend/src/server/api/ApiCallService.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* fix

* kind: permission for account moved error

* also for suspended error

* Update packages/backend/src/server/api/StreamingApiServerService.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

---------

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
Kagami Sascha Rosylight 2023-06-28 06:37:13 +02:00 committed by GitHub
parent d48172e9d1
commit 1b1f82a2e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 222 additions and 52 deletions

View file

@ -53,44 +53,72 @@ export class ApiCallService implements OnApplicationShutdown {
}, 1000 * 60 * 60); }, 1000 * 60 * 60);
} }
#sendApiError(reply: FastifyReply, err: ApiError): void {
let statusCode = err.httpStatusCode;
if (err.httpStatusCode === 401) {
reply.header('WWW-Authenticate', 'Bearer realm="Misskey"');
} else if (err.kind === 'client') {
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`);
statusCode = statusCode ?? 400;
} else if (err.kind === 'permission') {
// (ROLE_PERMISSION_DENIEDは関係ない)
if (err.code === 'PERMISSION_DENIED') {
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
}
statusCode = statusCode ?? 403;
} else if (!statusCode) {
statusCode = 500;
}
this.send(reply, statusCode, err);
}
#sendAuthenticationError(reply: FastifyReply, err: unknown): void {
if (err instanceof AuthenticationError) {
const message = 'Authentication failed. Please ensure your token is correct.';
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_token", error_description="${message}"`);
this.send(reply, 401, new ApiError({
message: 'Authentication failed. Please ensure your token is correct.',
code: 'AUTHENTICATION_FAILED',
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
}));
} else {
this.send(reply, 500, new ApiError());
}
}
@bindThis @bindThis
public handleRequest( public handleRequest(
endpoint: IEndpoint & { exec: any }, endpoint: IEndpoint & { exec: any },
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>, request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply, reply: FastifyReply,
) { ): void {
const body = request.method === 'GET' const body = request.method === 'GET'
? request.query ? request.query
: request.body; : request.body;
const token = body?.['i']; // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
const token = request.headers.authorization?.startsWith('Bearer ')
? request.headers.authorization.slice(7)
: body?.['i'];
if (token != null && typeof token !== 'string') { if (token != null && typeof token !== 'string') {
reply.code(400); reply.code(400);
return; return;
} }
this.authenticateService.authenticate(token).then(([user, app]) => { this.authenticateService.authenticate(token).then(([user, app]) => {
this.call(endpoint, user, app, body, null, request).then((res) => { this.call(endpoint, user, app, body, null, request).then((res) => {
if (request.method === 'GET' && endpoint.meta.cacheSec && !body?.['i'] && !user) { if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) {
reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
} }
this.send(reply, res); this.send(reply, res);
}).catch((err: ApiError) => { }).catch((err: ApiError) => {
this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); this.#sendApiError(reply, err);
}); });
if (user) { if (user) {
this.logIp(request, user); this.logIp(request, user);
} }
}).catch(err => { }).catch(err => {
if (err instanceof AuthenticationError) { this.#sendAuthenticationError(reply, err);
this.send(reply, 401, new ApiError({
message: 'Authentication failed. Please ensure your token is correct.',
code: 'AUTHENTICATION_FAILED',
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
}));
} else {
this.send(reply, 500, new ApiError());
}
}); });
} }
@ -99,7 +127,7 @@ export class ApiCallService implements OnApplicationShutdown {
endpoint: IEndpoint & { exec: any }, endpoint: IEndpoint & { exec: any },
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>, request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
reply: FastifyReply, reply: FastifyReply,
) { ): Promise<void> {
const multipartData = await request.file().catch(() => { const multipartData = await request.file().catch(() => {
/* Fastify throws if the remote didn't send multipart data. Return 400 below. */ /* Fastify throws if the remote didn't send multipart data. Return 400 below. */
}); });
@ -117,7 +145,10 @@ export class ApiCallService implements OnApplicationShutdown {
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;
} }
const token = fields['i']; // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
const token = request.headers.authorization?.startsWith('Bearer ')
? request.headers.authorization.slice(7)
: fields['i'];
if (token != null && typeof token !== 'string') { if (token != null && typeof token !== 'string') {
reply.code(400); reply.code(400);
return; return;
@ -129,22 +160,14 @@ export class ApiCallService implements OnApplicationShutdown {
}, request).then((res) => { }, request).then((res) => {
this.send(reply, res); this.send(reply, res);
}).catch((err: ApiError) => { }).catch((err: ApiError) => {
this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); this.#sendApiError(reply, err);
}); });
if (user) { if (user) {
this.logIp(request, user); this.logIp(request, user);
} }
}).catch(err => { }).catch(err => {
if (err instanceof AuthenticationError) { this.#sendAuthenticationError(reply, err);
this.send(reply, 401, new ApiError({
message: 'Authentication failed. Please ensure your token is correct.',
code: 'AUTHENTICATION_FAILED',
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
}));
} else {
this.send(reply, 500, new ApiError());
}
}); });
} }
@ -213,7 +236,7 @@ export class ApiCallService implements OnApplicationShutdown {
} }
if (ep.meta.limit) { if (ep.meta.limit) {
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor: string; let limitActor: string;
if (user) { if (user) {
limitActor = user.id; limitActor = user.id;
@ -255,8 +278,8 @@ export class ApiCallService implements OnApplicationShutdown {
throw new ApiError({ throw new ApiError({
message: 'Your account has been suspended.', message: 'Your account has been suspended.',
code: 'YOUR_ACCOUNT_SUSPENDED', code: 'YOUR_ACCOUNT_SUSPENDED',
kind: 'permission',
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
httpStatusCode: 403,
}); });
} }
} }
@ -266,8 +289,8 @@ export class ApiCallService implements OnApplicationShutdown {
throw new ApiError({ throw new ApiError({
message: 'You have moved your account.', message: 'You have moved your account.',
code: 'YOUR_ACCOUNT_MOVED', code: 'YOUR_ACCOUNT_MOVED',
kind: 'permission',
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
httpStatusCode: 403,
}); });
} }
} }
@ -321,7 +344,7 @@ export class ApiCallService implements OnApplicationShutdown {
try { try {
data[k] = JSON.parse(data[k]); data[k] = JSON.parse(data[k]);
} catch (e) { } catch (e) {
throw new ApiError({ throw new ApiError({
message: 'Invalid param.', message: 'Invalid param.',
code: 'INVALID_PARAM', code: 'INVALID_PARAM',
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85', id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',

View file

@ -58,11 +58,21 @@ export class StreamingApiServerService {
let user: LocalUser | null = null; let user: LocalUser | null = null;
let app: AccessToken | null = null; let app: AccessToken | null = null;
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1
// Note that the standard WHATWG WebSocket API does not support setting any headers,
// but non-browser apps may still be able to set it.
const token = request.headers.authorization?.startsWith('Bearer ')
? request.headers.authorization.slice(7)
: q.get('i');
try { try {
[user, app] = await this.authenticateService.authenticate(q.get('i')); [user, app] = await this.authenticateService.authenticate(token);
} catch (e) { } catch (e) {
if (e instanceof AuthenticationError) { if (e instanceof AuthenticationError) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.write([
'HTTP/1.1 401 Unauthorized',
'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"',
].join('\r\n') + '\r\n\r\n');
} else { } else {
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
} }

View file

@ -1,9 +1,10 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { signup, api, startServer, successfulApiCall, failedApiCall } from '../utils.js'; import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common'; import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
import { IncomingMessage } from 'http';
describe('API', () => { describe('API', () => {
let app: INestApplicationContext; let app: INestApplicationContext;
@ -123,4 +124,100 @@ describe('API', () => {
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
}); });
}); });
describe('Authentication header', () => {
test('一般リクエスト', async () => {
await successfulApiCall({
endpoint: '/admin/get-index-stats',
parameters: {},
user: {
token: alice.token,
bearer: true,
},
});
});
test('multipartリクエスト', async () => {
const result = await uploadFile({
token: alice.token,
bearer: true,
});
assert.strictEqual(result.status, 200);
});
test('streaming', async () => {
const fired = await waitFire(
{
token: alice.token,
bearer: true,
},
'homeTimeline',
() => api('notes/create', { text: 'foo' }, alice),
msg => msg.type === 'note' && msg.body.text === 'foo',
);
assert.strictEqual(fired, true);
});
});
describe('tokenエラー応答でWWW-Authenticate headerを送る', () => {
describe('invalid_token', () => {
test('一般リクエスト', async () => {
const result = await api('/admin/get-index-stats', {}, {
token: 'syuilo',
bearer: true,
});
assert.strictEqual(result.status, 401);
assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
});
test('multipartリクエスト', async () => {
const result = await uploadFile({
token: 'syuilo',
bearer: true,
});
assert.strictEqual(result.status, 401);
assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
});
test('streaming', async () => {
await assert.rejects(connectStream(
{
token: 'syuilo',
bearer: true,
},
'homeTimeline',
() => { },
), (err: IncomingMessage) => {
assert.strictEqual(err.statusCode, 401);
assert.ok(err.headers['www-authenticate']?.startsWith('Bearer realm="Misskey", error="invalid_token", error_description'));
return true;
});
});
});
describe('tokenがないとrealmだけおくる', () => {
test('一般リクエスト', async () => {
const result = await api('/admin/get-index-stats', {});
assert.strictEqual(result.status, 401);
assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"');
});
test('multipartリクエスト', async () => {
const result = await uploadFile();
assert.strictEqual(result.status, 401);
assert.strictEqual(result.headers.get('WWW-Authenticate'), 'Bearer realm="Misskey"');
});
});
test('invalid_request', async () => {
const result = await api('/notes/create', { text: true }, {
token: alice.token,
bearer: true,
});
assert.strictEqual(result.status, 400);
assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description'));
});
// TODO: insufficient_scope test (authテストが全然なくて書けない)
});
}); });

View file

@ -2,7 +2,7 @@ import * as assert from 'node:assert';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { isAbsolute, basename } from 'node:path'; import { isAbsolute, basename } from 'node:path';
import { inspect } from 'node:util'; import { inspect } from 'node:util';
import WebSocket from 'ws'; import WebSocket, { ClientOptions } from 'ws';
import fetch, { Blob, File, RequestInit } from 'node-fetch'; import fetch, { Blob, File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
@ -13,7 +13,10 @@ import type * as misskey from 'misskey-js';
export { server as startServer } from '@/boot/common.js'; export { server as startServer } from '@/boot/common.js';
interface UserToken { token: string } interface UserToken {
token: string;
bearer?: boolean;
}
const config = loadConfig(); const config = loadConfig();
export const port = config.port; export const port = config.port;
@ -57,27 +60,33 @@ export const failedApiCall = async <T, >(request: ApiRequest, assertion: {
return res.body; return res.body;
}; };
const request = async (path: string, params: any, me?: UserToken): Promise<{ body: any, status: number }> => { const request = async (path: string, params: any, me?: UserToken): Promise<{ status: number, headers: Headers, body: any }> => {
const auth = me ? { const bodyAuth: Record<string, string> = {};
i: me.token, const headers: Record<string, string> = {
} : {}; 'Content-Type': 'application/json',
};
if (me?.bearer) {
headers.Authorization = `Bearer ${me.token}`;
} else if (me) {
bodyAuth.i = me.token;
}
const res = await relativeFetch(path, { const res = await relativeFetch(path, {
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json', body: JSON.stringify(Object.assign(bodyAuth, params)),
},
body: JSON.stringify(Object.assign(auth, params)),
redirect: 'manual', redirect: 'manual',
}); });
const status = res.status;
const body = res.headers.get('content-type') === 'application/json; charset=utf-8' const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
? await res.json() ? await res.json()
: null; : null;
return { return {
body, status, status: res.status,
headers: res.headers,
body,
}; };
}; };
@ -241,7 +250,7 @@ interface UploadOptions {
* Upload file * Upload file
* @param user User * @param user User
*/ */
export const uploadFile = async (user: UserToken, { path, name, blob }: UploadOptions = {}): Promise<any> => { export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadOptions = {}): Promise<{ status: number, headers: Headers, body: misskey.Endpoints['drive/files/create']['res'] | null }> => {
const absPath = path == null const absPath = path == null
? new URL('resources/Lenna.jpg', import.meta.url) ? new URL('resources/Lenna.jpg', import.meta.url)
: isAbsolute(path.toString()) : isAbsolute(path.toString())
@ -249,7 +258,6 @@ export const uploadFile = async (user: UserToken, { path, name, blob }: UploadOp
: new URL(path, new URL('resources/', import.meta.url)); : new URL(path, new URL('resources/', import.meta.url));
const formData = new FormData(); const formData = new FormData();
formData.append('i', user.token);
formData.append('file', blob ?? formData.append('file', blob ??
new File([await readFile(absPath)], basename(absPath.toString()))); new File([await readFile(absPath)], basename(absPath.toString())));
formData.append('force', 'true'); formData.append('force', 'true');
@ -257,15 +265,24 @@ export const uploadFile = async (user: UserToken, { path, name, blob }: UploadOp
formData.append('name', name); formData.append('name', name);
} }
const headers: Record<string, string> = {};
if (user?.bearer) {
headers.Authorization = `Bearer ${user.token}`;
} else if (user) {
formData.append('i', user.token);
}
const res = await relativeFetch('api/drive/files/create', { const res = await relativeFetch('api/drive/files/create', {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers,
}); });
const body = res.status !== 204 ? await res.json() : null; const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null;
return { return {
status: res.status, status: res.status,
headers: res.headers,
body, body,
}; };
}; };
@ -294,8 +311,16 @@ export const uploadUrl = async (user: UserToken, url: string) => {
export function connectStream(user: UserToken, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> { export function connectStream(user: UserToken, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`); const url = new URL(`ws://127.0.0.1:${port}/streaming`);
const options: ClientOptions = {};
if (user.bearer) {
options.headers = { Authorization: `Bearer ${user.token}` };
} else {
url.searchParams.set('i', user.token);
}
const ws = new WebSocket(url, options);
ws.on('unexpected-response', (req, res) => rej(res));
ws.on('open', () => { ws.on('open', () => {
ws.on('message', data => { ws.on('message', data => {
const msg = JSON.parse(data.toString()); const msg = JSON.parse(data.toString());

View file

@ -960,8 +960,14 @@ export type Endpoints = {
res: TODO; res: TODO;
}; };
'drive/files/create': { 'drive/files/create': {
req: TODO; req: {
res: TODO; folderId?: string;
name?: string;
comment?: string;
isSentisive?: boolean;
force?: boolean;
};
res: DriveFile;
}; };
'drive/files/delete': { 'drive/files/delete': {
req: { req: {
@ -2750,7 +2756,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
// //
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:611:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/api.types.ts:620:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package) // (No @packageDocumentation comment for this package)

View file

@ -262,7 +262,16 @@ export type Endpoints = {
'drive/files': { req: { folderId?: DriveFolder['id'] | null; type?: DriveFile['type'] | null; limit?: number; sinceId?: DriveFile['id']; untilId?: DriveFile['id']; }; res: DriveFile[]; }; 'drive/files': { req: { folderId?: DriveFolder['id'] | null; type?: DriveFile['type'] | null; limit?: number; sinceId?: DriveFile['id']; untilId?: DriveFile['id']; }; res: DriveFile[]; };
'drive/files/attached-notes': { req: TODO; res: TODO; }; 'drive/files/attached-notes': { req: TODO; res: TODO; };
'drive/files/check-existence': { req: TODO; res: TODO; }; 'drive/files/check-existence': { req: TODO; res: TODO; };
'drive/files/create': { req: TODO; res: TODO; }; 'drive/files/create': {
req: {
folderId?: string,
name?: string,
comment?: string,
isSentisive?: boolean,
force?: boolean,
};
res: DriveFile;
};
'drive/files/delete': { req: { fileId: DriveFile['id']; }; res: null; }; 'drive/files/delete': { req: { fileId: DriveFile['id']; }; res: null; };
'drive/files/find-by-hash': { req: TODO; res: TODO; }; 'drive/files/find-by-hash': { req: TODO; res: TODO; };
'drive/files/find': { req: { name: string; folderId?: DriveFolder['id'] | null; }; res: DriveFile[]; }; 'drive/files/find': { req: { name: string; folderId?: DriveFolder['id'] | null; }; res: DriveFile[]; };