refs #816 Add support for Misskey login

This commit is contained in:
AkiraFukushima 2020-03-18 00:21:57 +09:00
parent dec2e75ab9
commit f9653647e4
12 changed files with 110 additions and 47 deletions

6
package-lock.json generated
View File

@ -16593,9 +16593,9 @@
"dev": true "dev": true
}, },
"megalodon": { "megalodon": {
"version": "3.0.0-beta.4", "version": "3.0.0-rc.3",
"resolved": "https://registry.npmjs.org/megalodon/-/megalodon-3.0.0-beta.4.tgz", "resolved": "https://registry.npmjs.org/megalodon/-/megalodon-3.0.0-rc.3.tgz",
"integrity": "sha512-u1uf4C0A/l/x/QDe29rR+fvmxak9j5cNGoN64Aypb7IsCzrX/3gASQjcK88H8AhOgxmogETQwEuGI1JAPIx/zw==", "integrity": "sha512-EMXBBw/U0buIZzOBX0XhPvGJ8WTfo4I3Xxl5UQj3fGqUsdb3LJqCE232oo9zoAiktFP0JYbrEXgUkvG+w70R1w==",
"requires": { "requires": {
"@types/oauth": "^0.9.0", "@types/oauth": "^0.9.0",
"@types/ws": "^7.2.0", "@types/ws": "^7.2.0",

View File

@ -184,7 +184,7 @@
"hoek": "^6.1.3", "hoek": "^6.1.3",
"i18next": "^19.0.3", "i18next": "^19.0.3",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"megalodon": "3.0.0-beta.4", "megalodon": "3.0.0-rc.3",
"moment": "^2.24.0", "moment": "^2.24.0",
"mousetrap": "^1.6.3", "mousetrap": "^1.6.3",
"nedb": "^1.8.0", "nedb": "^1.8.0",

View File

@ -14,7 +14,8 @@ jest.mock('megalodon', () => ({
const state = (): LoginState => { const state = (): LoginState => {
return { return {
selectedInstance: null, selectedInstance: null,
searching: false searching: false,
sns: 'mastodon'
} }
} }

View File

@ -6,7 +6,8 @@ describe('Login', () => {
beforeEach(() => { beforeEach(() => {
state = { state = {
selectedInstance: null, selectedInstance: null,
searching: false searching: false,
sns: 'mastodon'
} }
}) })
describe('changeInstance', () => { describe('changeInstance', () => {

View File

@ -97,7 +97,7 @@ export default class Account {
listAccounts(): Promise<Array<LocalAccount>> { listAccounts(): Promise<Array<LocalAccount>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.db this.db
.find<LocalAccount>({ accessToken: { $ne: '' } }) .find<LocalAccount>({ $and: [{ accessToken: { $ne: '' } }, { accessToken: { $ne: null } }] })
.sort({ order: 1 }) .sort({ order: 1 })
.exec((err, docs) => { .exec((err, docs) => {
if (err) return reject(err) if (err) return reject(err)

View File

@ -1,45 +1,48 @@
import generator, { ProxyConfig, detector } from 'megalodon' import generator, { ProxyConfig } from 'megalodon'
import crypto from 'crypto'
import Account from './account' import Account from './account'
import { LocalAccount } from '~/src/types/localAccount' import { LocalAccount } from '~/src/types/localAccount'
const appName = 'Whalebird' const appName = 'Whalebird'
const appURL = 'https://whalebird.org' const appURL = 'https://whalebird.org'
const scopes = ['read', 'write', 'follow']
export default class Authentication { export default class Authentication {
private db: Account private db: Account
private baseURL: string private baseURL: string | null = null
private domain: string private domain: string | null = null
private clientId: string private clientId: string | null = null
private clientSecret: string private clientSecret: string | null = null
private sessionToken: string | null = null
private protocol: 'http' | 'https' private protocol: 'http' | 'https'
constructor(accountDB: Account) { constructor(accountDB: Account) {
this.db = accountDB this.db = accountDB
this.baseURL = ''
this.domain = ''
this.clientId = ''
this.clientSecret = ''
this.protocol = 'https' this.protocol = 'https'
} }
setOtherInstance(domain: string) { setOtherInstance(domain: string) {
this.baseURL = `${this.protocol}://${domain}` this.baseURL = `${this.protocol}://${domain}`
this.domain = domain this.domain = domain
this.clientId = '' this.clientId = null
this.clientSecret = '' this.clientSecret = null
} }
async getAuthorizationUrl(domain = 'mastodon.social', proxy: ProxyConfig | false): Promise<string> { async getAuthorizationUrl(
sns: 'mastodon' | 'pleroma' | 'misskey',
domain: string = 'mastodon.social',
proxy: ProxyConfig | false
): Promise<string> {
this.setOtherInstance(domain) 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 client = generator(sns, this.baseURL, null, 'Whalebird', proxy)
const res = await client.registerApp(appName, { const res = await client.registerApp(appName, {
scopes: scopes,
website: appURL website: appURL
}) })
this.clientId = res.clientId this.clientId = res.clientId
this.clientSecret = res.clientSecret this.clientSecret = res.clientSecret
this.sessionToken = res.session_token
const order = await this.db const order = await this.db
.lastAccount() .lastAccount()
@ -53,11 +56,11 @@ export default class Authentication {
domain: this.domain, domain: this.domain,
clientId: this.clientId, clientId: this.clientId,
clientSecret: this.clientSecret, clientSecret: this.clientSecret,
accessToken: '', accessToken: null,
refreshToken: null, refreshToken: null,
username: '', username: null,
accountId: null, accountId: null,
avatar: '', avatar: null,
order: order order: order
} }
await this.db.insertAccount(local) await this.db.insertAccount(local)
@ -67,10 +70,24 @@ export default class Authentication {
return res.url return res.url
} }
async getAccessToken(code: string, proxy: ProxyConfig | false): Promise<string> { async getAccessToken(sns: 'mastodon' | 'pleroma' | 'misskey', code: string | null, proxy: ProxyConfig | false): Promise<string> {
const sns = await detector(this.baseURL, proxy) 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 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 = { const search = {
baseURL: this.baseURL, baseURL: this.baseURL,
domain: this.domain, domain: this.domain,
@ -78,7 +95,14 @@ export default class Authentication {
clientSecret: this.clientSecret clientSecret: this.clientSecret
} }
const rec = await this.db.searchAccount(search) 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 refreshToken = tokenData.refreshToken
const data = await this.db.fetchAccount(sns, rec, accessToken, proxy) const data = await this.db.fetchAccount(sns, rec, accessToken, proxy)
await this.db.updateAccount(rec._id!, { await this.db.updateAccount(rec._id!, {

View File

@ -364,10 +364,15 @@ app.on('activate', () => {
let auth = new Authentication(accountManager) 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() const proxy = await proxyConfiguration.forMastodon()
auth auth
.getAuthorizationUrl(domain, proxy) .getAuthorizationUrl(request.sns, request.instance, proxy)
.then(url => { .then(url => {
log.debug(url) log.debug(url)
event.sender.send('response-get-auth-url', 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() const proxy = await proxyConfiguration.forMastodon()
auth auth
.getAccessToken(code, proxy) .getAccessToken(request.sns, request.code, proxy)
.then(token => { .then(token => {
accountDB.findOne( accountDB.findOne(
{ {

View File

@ -22,7 +22,7 @@
class="authorize-form" class="authorize-form"
v-on:submit.prevent="authorizeSubmit" v-on:submit.prevent="authorizeSubmit"
> >
<el-form-item :label="$t('authorize.code_label')"> <el-form-item :label="$t('authorize.code_label')" v-show="sns !== 'misskey'">
<el-input v-model="authorizeForm.code"></el-input> <el-input v-model="authorizeForm.code"></el-input>
</el-form-item> </el-form-item>
<!-- Dummy form to guard submitting with enter --> <!-- Dummy form to guard submitting with enter -->
@ -47,12 +47,16 @@ export default {
url: { url: {
type: String, type: String,
default: '' default: ''
},
sns: {
type: String,
default: 'mastodon'
} }
}, },
data() { data() {
return { return {
authorizeForm: { authorizeForm: {
code: '' code: null
}, },
submitting: false submitting: false
} }
@ -64,7 +68,10 @@ export default {
authorizeSubmit() { authorizeSubmit() {
this.submitting = true this.submitting = true
this.$store this.$store
.dispatch('Authorize/submit', this.authorizeForm.code) .dispatch('Authorize/submit', {
code: this.authorizeForm.code,
sns: this.sns
})
.finally(() => { .finally(() => {
this.submitting = false this.submitting = false
}) })

View File

@ -45,7 +45,8 @@ export default {
computed: { computed: {
...mapState({ ...mapState({
selectedInstance: state => state.Login.selectedInstance, selectedInstance: state => state.Login.selectedInstance,
searching: state => state.Login.searching searching: state => state.Login.searching,
sns: state => state.Login.sns
}), }),
allowLogin: function() { allowLogin: function() {
return this.selectedInstance && this.form.domainName === this.selectedInstance return this.selectedInstance && this.form.domainName === this.selectedInstance
@ -78,11 +79,11 @@ export default {
background: 'rgba(0, 0, 0, 0.7)' background: 'rgba(0, 0, 0, 0.7)'
}) })
this.$store this.$store
.dispatch('Login/fetchLogin', this.selectedInstance) .dispatch('Login/fetchLogin')
.then(url => { .then(url => {
loading.close() loading.close()
this.$store.dispatch('Login/pageBack') 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(() => { .catch(() => {
loading.close() loading.close()

View File

@ -44,7 +44,7 @@ const router = new Router({
path: '/authorize', path: '/authorize',
name: 'authorize', name: 'authorize',
component: Authorize, component: Authorize,
props: route => ({ url: route.query.url }) props: route => ({ url: route.query.url, sns: route.query.sns })
}, },
{ {
path: '/preferences/', path: '/preferences/',

View File

@ -9,14 +9,23 @@ export type AuthorizeState = {}
const state = (): AuthorizeState => ({}) const state = (): AuthorizeState => ({})
const actions: ActionTree<AuthorizeState, RootState> = { const actions: ActionTree<AuthorizeState, RootState> = {
submit: (_, code: string) => { submit: (_, request: { code: string | null; sns: 'mastodon' | 'pleroma' | 'misskey' }) => {
return new Promise((resolve, reject) => { 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.once('response-get-access-token', (_, id: string) => {
win.ipcRenderer.removeAllListeners('error-get-access-token') win.ipcRenderer.removeAllListeners('error-get-access-token')
resolve(id) resolve(id)
}) })
win.ipcRenderer.once('error-get-access-token', (_, err: Error) => { win.ipcRenderer.once('error-get-access-token', (_, err: Error) => {
console.error(err)
win.ipcRenderer.removeAllListeners('response-get-access-token') win.ipcRenderer.removeAllListeners('response-get-access-token')
reject(err) reject(err)
}) })

View File

@ -8,16 +8,19 @@ const win = window as MyWindow
export type LoginState = { export type LoginState = {
selectedInstance: string | null selectedInstance: string | null
searching: boolean searching: boolean
sns: 'mastodon' | 'pleroma' | 'misskey'
} }
const state = (): LoginState => ({ const state = (): LoginState => ({
selectedInstance: null, selectedInstance: null,
searching: false searching: false,
sns: 'mastodon'
}) })
export const MUTATION_TYPES = { export const MUTATION_TYPES = {
CHANGE_INSTANCE: 'changeInstance', CHANGE_INSTANCE: 'changeInstance',
CHANGE_SEARCHING: 'changeSearching' CHANGE_SEARCHING: 'changeSearching',
CHANGE_SNS: 'changeSNS'
} }
const mutations: MutationTree<LoginState> = { const mutations: MutationTree<LoginState> = {
@ -26,13 +29,19 @@ const mutations: MutationTree<LoginState> = {
}, },
[MUTATION_TYPES.CHANGE_SEARCHING]: (state: LoginState, searching: boolean) => { [MUTATION_TYPES.CHANGE_SEARCHING]: (state: LoginState, searching: boolean) => {
state.searching = searching state.searching = searching
},
[MUTATION_TYPES.CHANGE_SNS]: (state: LoginState, sns: 'mastodon' | 'pleroma' | 'misskey') => {
state.sns = sns
} }
} }
const actions: ActionTree<LoginState, RootState> = { const actions: ActionTree<LoginState, RootState> = {
fetchLogin: (_, instance: string) => { fetchLogin: ({ state }) => {
return new Promise((resolve, reject) => { 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.once('error-get-auth-url', (_, err: Error) => {
win.ipcRenderer.removeAllListeners('response-get-auth-url') win.ipcRenderer.removeAllListeners('response-get-auth-url')
reject(err) reject(err)
@ -49,9 +58,10 @@ const actions: ActionTree<LoginState, RootState> = {
confirmInstance: async ({ commit, rootState }, domain: string): Promise<boolean> => { confirmInstance: async ({ commit, rootState }, domain: string): Promise<boolean> => {
commit(MUTATION_TYPES.CHANGE_SEARCHING, true) commit(MUTATION_TYPES.CHANGE_SEARCHING, true)
const cleanDomain = domain.trim() 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_SEARCHING, false)
commit(MUTATION_TYPES.CHANGE_INSTANCE, cleanDomain) commit(MUTATION_TYPES.CHANGE_INSTANCE, cleanDomain)
commit(MUTATION_TYPES.CHANGE_SNS, sns)
return true return true
} }
} }