refs #816 Add support for Misskey login
This commit is contained in:
parent
dec2e75ab9
commit
f9653647e4
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -14,7 +14,8 @@ jest.mock('megalodon', () => ({
|
|||
const state = (): LoginState => {
|
||||
return {
|
||||
selectedInstance: null,
|
||||
searching: false
|
||||
searching: false,
|
||||
sns: 'mastodon'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,8 @@ describe('Login', () => {
|
|||
beforeEach(() => {
|
||||
state = {
|
||||
selectedInstance: null,
|
||||
searching: false
|
||||
searching: false,
|
||||
sns: 'mastodon'
|
||||
}
|
||||
})
|
||||
describe('changeInstance', () => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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!, {
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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/',
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue