Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refs #816 Add support for Misskey login #1298

Merged
merged 4 commits into from
Mar 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"moment": "^2.24.0",
"mousetrap": "^1.6.3",
"nedb": "^1.8.0",
Expand Down
3 changes: 2 additions & 1 deletion spec/renderer/integration/store/Login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ jest.mock('megalodon', () => ({
const state = (): LoginState => {
return {
selectedInstance: null,
searching: false
searching: false,
sns: 'mastodon'
}
}

Expand Down
3 changes: 2 additions & 1 deletion spec/renderer/unit/store/Login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ describe('Login', () => {
beforeEach(() => {
state = {
selectedInstance: null,
searching: false
searching: false,
sns: 'mastodon'
}
})
describe('changeInstance', () => {
Expand Down
1 change: 1 addition & 0 deletions src/config/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@
"manually_1": "An authorization page has opened in your browser.",
"manually_2": "If it has not opened, please go to the following URL manually.",
"code_label": "Please paste the authorization code from your browser:",
"misskey_label": "Please submit after you authorize in your browser.",
"submit": "Submit"
},
"receive_drop": {
Expand Down
2 changes: 1 addition & 1 deletion src/main/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 46 additions & 22 deletions src/main/auth.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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)
Expand All @@ -67,18 +70,39 @@ 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,
clientId: this.clientId,
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!, {
Expand Down
18 changes: 14 additions & 4 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
{
Expand Down
14 changes: 11 additions & 3 deletions src/renderer/components/Authorize.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
class="authorize-form"
v-on:submit.prevent="authorizeSubmit"
>
<el-form-item :label="$t('authorize.code_label')">
<p v-if="sns === 'misskey'">{{ $t('authorize.misskey_label') }}</p>
<el-form-item :label="$t('authorize.code_label')" v-else>
<el-input v-model="authorizeForm.code"></el-input>
</el-form-item>
<!-- Dummy form to guard submitting with enter -->
Expand All @@ -47,12 +48,16 @@ export default {
url: {
type: String,
default: ''
},
sns: {
type: String,
default: 'mastodon'
}
},
data() {
return {
authorizeForm: {
code: ''
code: null
},
submitting: false
}
Expand All @@ -64,7 +69,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
})
Expand Down
7 changes: 4 additions & 3 deletions src/renderer/components/Login/LoginForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<template>
<div id="follows">
<template v-for="follow in follows">
<user :user="follow"
v-bind:key="follow.id"
:relationship="targetRelation(follow.id)"
@followAccount="followAccount"
@unfollowAccount="unfollowAccount"
>
</user>
</template>
</div>
<div id="follows">
<template v-for="follow in follows">
<user
:user="follow"
v-bind:key="follow.id"
:relationship="targetRelation(follow.id)"
@followAccount="followAccount"
@unfollowAccount="unfollowAccount"
>
</user>
</template>
</div>
</template>

<script>
Expand All @@ -18,29 +19,30 @@ import User from '~/src/renderer/components/molecules/User'

export default {
name: 'follows',
props: [ 'account' ],
props: ['account'],
components: { User },
computed: {
...mapState('TimelineSpace/Contents/SideBar/AccountProfile/Follows', {
follows: state => state.follows,
relationships: state => state.relationships
})
},
created () {
created() {
this.load()
},
watch: {
account: function (_newAccount, _oldAccount) {
account: function(_newAccount, _oldAccount) {
this.load()
}
},
methods: {
async load () {
async load() {
this.$store.commit('TimelineSpace/Contents/SideBar/AccountProfile/changeLoading', true)
try {
const follows = await this.$store.dispatch('TimelineSpace/Contents/SideBar/AccountProfile/Follows/fetchFollows', this.account)
await this.$store.dispatch('TimelineSpace/Contents/SideBar/AccountProfile/Follows/fetchRelationships', follows)
} catch (err) {
console.error(err)
this.$message({
message: this.$t('message.follows_fetch_error'),
type: 'error'
Expand All @@ -49,10 +51,10 @@ export default {
this.$store.commit('TimelineSpace/Contents/SideBar/AccountProfile/changeLoading', false)
}
},
targetRelation (id) {
targetRelation(id) {
return this.relationships.find(r => r.id === id)
},
async followAccount (account) {
async followAccount(account) {
this.$store.commit('TimelineSpace/Contents/SideBar/AccountProfile/changeLoading', true)
try {
await this.$store.dispatch('TimelineSpace/Contents/SideBar/AccountProfile/follow', account)
Expand All @@ -66,7 +68,7 @@ export default {
this.$store.commit('TimelineSpace/Contents/SideBar/AccountProfile/changeLoading', false)
}
},
async unfollowAccount (account) {
async unfollowAccount(account) {
this.$store.commit('TimelineSpace/Contents/SideBar/AccountProfile/changeLoading', true)
try {
await this.$store.dispatch('TimelineSpace/Contents/SideBar/AccountProfile/unfollow', account)
Expand All @@ -84,5 +86,4 @@ export default {
}
</script>

<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>
2 changes: 1 addition & 1 deletion src/renderer/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand Down