From f9653647e484f721f86c6bbde0296c76e3adda65 Mon Sep 17 00:00:00 2001 From: AkiraFukushima Date: Wed, 18 Mar 2020 00:21:57 +0900 Subject: [PATCH] refs #816 Add support for Misskey login --- package-lock.json | 6 +- package.json | 2 +- spec/renderer/integration/store/Login.spec.ts | 3 +- spec/renderer/unit/store/Login.spec.ts | 3 +- src/main/account.ts | 2 +- src/main/auth.ts | 68 +++++++++++++------ src/main/index.ts | 18 +++-- src/renderer/components/Authorize.vue | 13 +++- src/renderer/components/Login/LoginForm.vue | 7 +- src/renderer/router/index.ts | 2 +- src/renderer/store/Authorize.ts | 13 +++- src/renderer/store/Login.ts | 20 ++++-- 12 files changed, 110 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index a342e8bc..565213a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16593,9 +16593,9 @@ "dev": true }, "megalodon": { - "version": "3.0.0-beta.4", - "resolved": "https://registry.npmjs.org/megalodon/-/megalodon-3.0.0-beta.4.tgz", - "integrity": "sha512-u1uf4C0A/l/x/QDe29rR+fvmxak9j5cNGoN64Aypb7IsCzrX/3gASQjcK88H8AhOgxmogETQwEuGI1JAPIx/zw==", + "version": "3.0.0-rc.3", + "resolved": "https://registry.npmjs.org/megalodon/-/megalodon-3.0.0-rc.3.tgz", + "integrity": "sha512-EMXBBw/U0buIZzOBX0XhPvGJ8WTfo4I3Xxl5UQj3fGqUsdb3LJqCE232oo9zoAiktFP0JYbrEXgUkvG+w70R1w==", "requires": { "@types/oauth": "^0.9.0", "@types/ws": "^7.2.0", diff --git a/package.json b/package.json index 5a41f325..9717c802 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,7 @@ "hoek": "^6.1.3", "i18next": "^19.0.3", "lodash": "^4.17.15", - "megalodon": "3.0.0-beta.4", + "megalodon": "3.0.0-rc.3", "moment": "^2.24.0", "mousetrap": "^1.6.3", "nedb": "^1.8.0", diff --git a/spec/renderer/integration/store/Login.spec.ts b/spec/renderer/integration/store/Login.spec.ts index 6e871b83..95f20018 100644 --- a/spec/renderer/integration/store/Login.spec.ts +++ b/spec/renderer/integration/store/Login.spec.ts @@ -14,7 +14,8 @@ jest.mock('megalodon', () => ({ const state = (): LoginState => { return { selectedInstance: null, - searching: false + searching: false, + sns: 'mastodon' } } diff --git a/spec/renderer/unit/store/Login.spec.ts b/spec/renderer/unit/store/Login.spec.ts index a35fb807..e6861b3b 100644 --- a/spec/renderer/unit/store/Login.spec.ts +++ b/spec/renderer/unit/store/Login.spec.ts @@ -6,7 +6,8 @@ describe('Login', () => { beforeEach(() => { state = { selectedInstance: null, - searching: false + searching: false, + sns: 'mastodon' } }) describe('changeInstance', () => { diff --git a/src/main/account.ts b/src/main/account.ts index bcf3cc4b..a16904a5 100644 --- a/src/main/account.ts +++ b/src/main/account.ts @@ -97,7 +97,7 @@ export default class Account { listAccounts(): Promise> { return new Promise((resolve, reject) => { this.db - .find({ accessToken: { $ne: '' } }) + .find({ $and: [{ accessToken: { $ne: '' } }, { accessToken: { $ne: null } }] }) .sort({ order: 1 }) .exec((err, docs) => { if (err) return reject(err) diff --git a/src/main/auth.ts b/src/main/auth.ts index 54132814..b6d24e07 100644 --- a/src/main/auth.ts +++ b/src/main/auth.ts @@ -1,45 +1,48 @@ -import generator, { ProxyConfig, detector } from 'megalodon' +import generator, { ProxyConfig } from 'megalodon' +import crypto from 'crypto' import Account from './account' import { LocalAccount } from '~/src/types/localAccount' const appName = 'Whalebird' const appURL = 'https://whalebird.org' -const scopes = ['read', 'write', 'follow'] export default class Authentication { private db: Account - private baseURL: string - private domain: string - private clientId: string - private clientSecret: string + private baseURL: string | null = null + private domain: string | null = null + private clientId: string | null = null + private clientSecret: string | null = null + private sessionToken: string | null = null private protocol: 'http' | 'https' constructor(accountDB: Account) { this.db = accountDB - this.baseURL = '' - this.domain = '' - this.clientId = '' - this.clientSecret = '' this.protocol = 'https' } setOtherInstance(domain: string) { this.baseURL = `${this.protocol}://${domain}` this.domain = domain - this.clientId = '' - this.clientSecret = '' + this.clientId = null + this.clientSecret = null } - async getAuthorizationUrl(domain = 'mastodon.social', proxy: ProxyConfig | false): Promise { + async getAuthorizationUrl( + sns: 'mastodon' | 'pleroma' | 'misskey', + domain: string = 'mastodon.social', + proxy: ProxyConfig | false + ): Promise { this.setOtherInstance(domain) - const sns = await detector(this.baseURL, proxy) + if (!this.baseURL || !this.domain) { + throw new Error('domain is required') + } const client = generator(sns, this.baseURL, null, 'Whalebird', proxy) const res = await client.registerApp(appName, { - scopes: scopes, website: appURL }) this.clientId = res.clientId this.clientSecret = res.clientSecret + this.sessionToken = res.session_token const order = await this.db .lastAccount() @@ -53,11 +56,11 @@ export default class Authentication { domain: this.domain, clientId: this.clientId, clientSecret: this.clientSecret, - accessToken: '', + accessToken: null, refreshToken: null, - username: '', + username: null, accountId: null, - avatar: '', + avatar: null, order: order } await this.db.insertAccount(local) @@ -67,10 +70,24 @@ export default class Authentication { return res.url } - async getAccessToken(code: string, proxy: ProxyConfig | false): Promise { - const sns = await detector(this.baseURL, proxy) + async getAccessToken(sns: 'mastodon' | 'pleroma' | 'misskey', code: string | null, proxy: ProxyConfig | false): Promise { + if (!this.baseURL) { + throw new Error('domain is required') + } + if (!this.clientSecret) { + throw new Error('client secret is required') + } const client = generator(sns, this.baseURL, null, 'Whalebird', proxy) - const tokenData = await client.fetchAccessToken(this.clientId, this.clientSecret, code, 'urn:ietf:wg:oauth:2.0:oob') + + // In Misskey session token is required instead of authentication code. + let authCode = code + if (!code) { + authCode = this.sessionToken + } + if (!authCode) { + throw new Error('auth code is required') + } + const tokenData = await client.fetchAccessToken(this.clientId, this.clientSecret, authCode, 'urn:ietf:wg:oauth:2.0:oob') const search = { baseURL: this.baseURL, domain: this.domain, @@ -78,7 +95,14 @@ export default class Authentication { clientSecret: this.clientSecret } const rec = await this.db.searchAccount(search) - const accessToken = tokenData.accessToken + let accessToken = tokenData.accessToken + // In misskey, access token is sha256(userToken + clientSecret) + if (sns === 'misskey') { + accessToken = crypto + .createHash('sha256') + .update(tokenData.accessToken + this.clientSecret, 'utf8') + .digest('hex') + } const refreshToken = tokenData.refreshToken const data = await this.db.fetchAccount(sns, rec, accessToken, proxy) await this.db.updateAccount(rec._id!, { diff --git a/src/main/index.ts b/src/main/index.ts index 3e42ccf7..8fcacf29 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -364,10 +364,15 @@ app.on('activate', () => { let auth = new Authentication(accountManager) -ipcMain.on('get-auth-url', async (event: IpcMainEvent, domain: string) => { +type AuthRequest = { + instance: string + sns: 'mastodon' | 'pleroma' | 'misskey' +} + +ipcMain.on('get-auth-url', async (event: IpcMainEvent, request: AuthRequest) => { const proxy = await proxyConfiguration.forMastodon() auth - .getAuthorizationUrl(domain, proxy) + .getAuthorizationUrl(request.sns, request.instance, proxy) .then(url => { log.debug(url) event.sender.send('response-get-auth-url', url) @@ -380,10 +385,15 @@ ipcMain.on('get-auth-url', async (event: IpcMainEvent, domain: string) => { }) }) -ipcMain.on('get-access-token', async (event: IpcMainEvent, code: string) => { +type TokenRequest = { + code: string | null + sns: 'mastodon' | 'pleroma' | 'misskey' +} + +ipcMain.on('get-access-token', async (event: IpcMainEvent, request: TokenRequest) => { const proxy = await proxyConfiguration.forMastodon() auth - .getAccessToken(code, proxy) + .getAccessToken(request.sns, request.code, proxy) .then(token => { accountDB.findOne( { diff --git a/src/renderer/components/Authorize.vue b/src/renderer/components/Authorize.vue index 4c1a699e..62e02877 100644 --- a/src/renderer/components/Authorize.vue +++ b/src/renderer/components/Authorize.vue @@ -22,7 +22,7 @@ class="authorize-form" v-on:submit.prevent="authorizeSubmit" > - + @@ -47,12 +47,16 @@ export default { url: { type: String, default: '' + }, + sns: { + type: String, + default: 'mastodon' } }, data() { return { authorizeForm: { - code: '' + code: null }, submitting: false } @@ -64,7 +68,10 @@ export default { authorizeSubmit() { this.submitting = true this.$store - .dispatch('Authorize/submit', this.authorizeForm.code) + .dispatch('Authorize/submit', { + code: this.authorizeForm.code, + sns: this.sns + }) .finally(() => { this.submitting = false }) diff --git a/src/renderer/components/Login/LoginForm.vue b/src/renderer/components/Login/LoginForm.vue index 5f91e97e..3d01e92d 100644 --- a/src/renderer/components/Login/LoginForm.vue +++ b/src/renderer/components/Login/LoginForm.vue @@ -45,7 +45,8 @@ export default { computed: { ...mapState({ selectedInstance: state => state.Login.selectedInstance, - searching: state => state.Login.searching + searching: state => state.Login.searching, + sns: state => state.Login.sns }), allowLogin: function() { return this.selectedInstance && this.form.domainName === this.selectedInstance @@ -78,11 +79,11 @@ export default { background: 'rgba(0, 0, 0, 0.7)' }) this.$store - .dispatch('Login/fetchLogin', this.selectedInstance) + .dispatch('Login/fetchLogin') .then(url => { loading.close() this.$store.dispatch('Login/pageBack') - this.$router.push({ path: '/authorize', query: { url: url } }) + this.$router.push({ path: '/authorize', query: { url: url, sns: this.sns } }) }) .catch(() => { loading.close() diff --git a/src/renderer/router/index.ts b/src/renderer/router/index.ts index ac82a23d..0a278c72 100644 --- a/src/renderer/router/index.ts +++ b/src/renderer/router/index.ts @@ -44,7 +44,7 @@ const router = new Router({ path: '/authorize', name: 'authorize', component: Authorize, - props: route => ({ url: route.query.url }) + props: route => ({ url: route.query.url, sns: route.query.sns }) }, { path: '/preferences/', diff --git a/src/renderer/store/Authorize.ts b/src/renderer/store/Authorize.ts index 6f1dc0bf..7ed061ab 100644 --- a/src/renderer/store/Authorize.ts +++ b/src/renderer/store/Authorize.ts @@ -9,14 +9,23 @@ export type AuthorizeState = {} const state = (): AuthorizeState => ({}) const actions: ActionTree = { - submit: (_, code: string) => { + submit: (_, request: { code: string | null; sns: 'mastodon' | 'pleroma' | 'misskey' }) => { return new Promise((resolve, reject) => { - win.ipcRenderer.send('get-access-token', code.trim()) + let req = { + sns: request.sns + } + if (request.code) { + req = Object.assign(req, { + code: request.code.trim() + }) + } + win.ipcRenderer.send('get-access-token', req) win.ipcRenderer.once('response-get-access-token', (_, id: string) => { win.ipcRenderer.removeAllListeners('error-get-access-token') resolve(id) }) win.ipcRenderer.once('error-get-access-token', (_, err: Error) => { + console.error(err) win.ipcRenderer.removeAllListeners('response-get-access-token') reject(err) }) diff --git a/src/renderer/store/Login.ts b/src/renderer/store/Login.ts index cbdaead7..66abe8a3 100644 --- a/src/renderer/store/Login.ts +++ b/src/renderer/store/Login.ts @@ -8,16 +8,19 @@ const win = window as MyWindow export type LoginState = { selectedInstance: string | null searching: boolean + sns: 'mastodon' | 'pleroma' | 'misskey' } const state = (): LoginState => ({ selectedInstance: null, - searching: false + searching: false, + sns: 'mastodon' }) export const MUTATION_TYPES = { CHANGE_INSTANCE: 'changeInstance', - CHANGE_SEARCHING: 'changeSearching' + CHANGE_SEARCHING: 'changeSearching', + CHANGE_SNS: 'changeSNS' } const mutations: MutationTree = { @@ -26,13 +29,19 @@ const mutations: MutationTree = { }, [MUTATION_TYPES.CHANGE_SEARCHING]: (state: LoginState, searching: boolean) => { state.searching = searching + }, + [MUTATION_TYPES.CHANGE_SNS]: (state: LoginState, sns: 'mastodon' | 'pleroma' | 'misskey') => { + state.sns = sns } } const actions: ActionTree = { - fetchLogin: (_, instance: string) => { + fetchLogin: ({ state }) => { return new Promise((resolve, reject) => { - win.ipcRenderer.send('get-auth-url', instance) + win.ipcRenderer.send('get-auth-url', { + instance: state.selectedInstance, + sns: state.sns + }) win.ipcRenderer.once('error-get-auth-url', (_, err: Error) => { win.ipcRenderer.removeAllListeners('response-get-auth-url') reject(err) @@ -49,9 +58,10 @@ const actions: ActionTree = { confirmInstance: async ({ commit, rootState }, domain: string): Promise => { commit(MUTATION_TYPES.CHANGE_SEARCHING, true) const cleanDomain = domain.trim() - await detector(`https://${cleanDomain}`, rootState.App.proxyConfiguration) + const sns = await detector(`https://${cleanDomain}`, rootState.App.proxyConfiguration) commit(MUTATION_TYPES.CHANGE_SEARCHING, false) commit(MUTATION_TYPES.CHANGE_INSTANCE, cleanDomain) + commit(MUTATION_TYPES.CHANGE_SNS, sns) return true } }