From 8646a9c49c708cd502904b450e16b405118312e3 Mon Sep 17 00:00:00 2001 From: "Acid Chicken (硫酸鶏)" Date: Sun, 4 Nov 2018 22:03:55 +0900 Subject: Add GitHub auth (#3095) --- src/server/api/service/github.ts | 267 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 263 insertions(+), 4 deletions(-) (limited to 'src/server/api/service/github.ts') diff --git a/src/server/api/service/github.ts b/src/server/api/service/github.ts index ac18cf90ae..3296f6fd69 100644 --- a/src/server/api/service/github.ts +++ b/src/server/api/service/github.ts @@ -1,11 +1,16 @@ import * as EventEmitter from 'events'; +import * as Koa from 'koa'; import * as Router from 'koa-router'; import * as request from 'request'; -const crypto = require('crypto'); - -import User, { IUser } from '../../../models/user'; +import { OAuth2 } from 'oauth'; +import User, { IUser, pack, ILocalUser } from '../../../models/user'; import createNote from '../../../services/note/create'; import config from '../../../config'; +import { publishMainStream } from '../../../stream'; +import redis from '../../../db/redis'; +import uuid = require('uuid'); +import signin from '../common/signin'; +const crypto = require('crypto'); const handler = new EventEmitter(); @@ -28,10 +33,264 @@ const post = async (text: string, home = true) => { createNote(bot, { text, visibility: home ? 'home' : 'public' }); }; +function getUserToken(ctx: Koa.Context) { + return ((ctx.headers['cookie'] || '').match(/i=(!\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.Context) { + function normalizeUrl(url: string) { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); +} + // Init router const router = new Router(); -if (config.github_bot != null) { +router.get('/disconnect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await User.findOneAndUpdate({ + host: null, + 'token': userToken + }, { + $set: { + 'github': null + } + }); + + ctx.body = `GitHubの連携を解除しました :v:`; + + // Publish i updated event + publishMainStream(user._id, 'meUpdated', await pack(user, user, { + detail: true, + includeSecrets: true + })); +}); + +if (!config.github || !redis) { + router.get('/connect/github', ctx => { + ctx.body = '現在GitHubへ接続できません (このインスタンスではGitHubはサポートされていません)'; + }); + + router.get('/signin/github', ctx => { + ctx.body = '現在GitHubへ接続できません (このインスタンスではGitHubはサポートされていません)'; + }); +} else { + const oauth2 = new OAuth2( + config.github.client_id, + config.github.client_secret, + 'https://github.com/', + 'login/oauth/authorize', + 'login/oauth/access_token'); + + router.get('/connect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${config.url}:8089/api/gh/cb`, + scope: ['read:user'], + state: uuid() + }; + + redis.set(userToken, JSON.stringify(params)); + ctx.redirect(oauth2.getAuthorizeUrl(params)); + }); + + router.get('/signin/github', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}:8089/api/gh/cb`, + scope: ['read:user'], + state: uuid() + }; + + const expires = 1000 * 60 * 60; // 1h + ctx.cookies.set('signin_with_github_session_id', sessid, { + path: '/', + domain: config.host, + secure: config.url.startsWith('https'), + httpOnly: true, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + redis.set(sessid, JSON.stringify(params)); + ctx.redirect(oauth2.getAuthorizeUrl(params)); + }); + + router.get('/gh/cb', async ctx => { + const userToken = getUserToken(ctx); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_github_session_id'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redis.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) + rej(err); + else if (result.error) + rej(result.error); + else + res({ accessToken }); + })); + + const { login, id } = await new Promise((res, rej) => + request({ + url: 'https://api.github.com/user', + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `bearer ${accessToken}`, + 'User-Agent': config.user_agent + } + }, (err, response, body) => { + if (err) + rej(err); + else + res(JSON.parse(body)); + })); + + if (!login || !id) { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await User.findOne({ + host: null, + 'github.id': id + }) as ILocalUser; + + if (!user) { + ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(ctx, user, true); + } else { + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redis.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) + rej(err); + else if (result.error) + rej(result.error); + else + res({ accessToken }); + })); + + const { login, id } = await new Promise((res, rej) => + request({ + url: 'https://api.github.com/user', + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `bearer ${accessToken}`, + 'User-Agent': config.user_agent + } + }, (err, response, body) => { + if (err) + rej(err); + else + res(JSON.parse(body)); + })); + + if (!login || !id) { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await User.findOneAndUpdate({ + host: null, + token: userToken + }, { + $set: { + github: { + accessToken, + id, + login + } + } + }); + + ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user._id, 'meUpdated', await pack(user, user, { + detail: true, + includeSecrets: true + })); + } + }); +} + +if (config.github_bot) { const secret = config.github_bot.hook_secret; router.post('/hooks/github', ctx => { -- cgit v1.2.3-freya