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