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
},
"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",

View File

@ -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",

View File

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

View File

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

View File

@ -97,7 +97,7 @@ export default class Account {
listAccounts(): Promise<Array<LocalAccount>> {
return new Promise((resolve, reject) => {
this.db
.find<LocalAccount>({ accessToken: { $ne: '' } })
.find<LocalAccount>({ $and: [{ accessToken: { $ne: '' } }, { accessToken: { $ne: null } }] })
.sort({ order: 1 })
.exec((err, docs) => {
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 { 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<string> {
async getAuthorizationUrl(
sns: 'mastodon' | 'pleroma' | 'misskey',
domain: string = 'mastodon.social',
proxy: ProxyConfig | false
): Promise<string> {
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<string> {
const sns = await detector(this.baseURL, proxy)
async getAccessToken(sns: 'mastodon' | 'pleroma' | 'misskey', code: string | null, proxy: ProxyConfig | false): Promise<string> {
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!, {

View File

@ -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(
{

View File

@ -22,7 +22,7 @@
class="authorize-form"
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-form-item>
<!-- Dummy form to guard submitting with enter -->
@ -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
})

View File

@ -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()

View File

@ -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/',

View File

@ -9,14 +9,23 @@ export type AuthorizeState = {}
const state = (): AuthorizeState => ({})
const actions: ActionTree<AuthorizeState, RootState> = {
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)
})

View File

@ -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<LoginState> = {
@ -26,13 +29,19 @@ const mutations: MutationTree<LoginState> = {
},
[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<LoginState, RootState> = {
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<LoginState, RootState> = {
confirmInstance: async ({ commit, rootState }, domain: string): Promise<boolean> => {
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
}
}