diff --git a/.config/docker_example.env b/.config/docker_example.env index c61248da2e4..310f41006af 100644 --- a/.config/docker_example.env +++ b/.config/docker_example.env @@ -9,3 +9,6 @@ POSTGRES_USER=example-misskey-user POSTGRES_DB=misskey # DATABASE_DB=${POSTGRES_DB} DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}" + +EVEXACCOUNT_CLIENT_ID=evex_CLIENT_ID +EVEXACCOUNT_CLIENT_SECRET=evex_secret_CLIENT_SECRET diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 4af17dd39e7..611fc4993ce 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -35,6 +35,22 @@ login: "ログイン" loggingIn: "ログイン中" logout: "ログアウト" signup: "新規登録" +evexAccount: + title: "EvexAccount" + addWith: "EvexAccountで追加" + signIn: "EvexAccountで続行" + signUp: "EvexAccountで作成" + goToSignup: "EvexAccountへ移動する" + signupDescription: "EvexAccountに登録すると、新しいMisskeyアカウントが作成されます。" + signInDescription: "このアカウントをEvexAccountで連携します。" + authorizeMessage: "このアプリを承認するにはEvexAccountで続行してください。" + signInOrContinue: "EvexAccountでサインインするか、リモートインスタンスで続行してください。" + welcomeCreateIntro: "EvexAccountで初期アカウントを作ってください。" + welcomeCreateButton: "EvexAccountで作る" + pleaseLoginRemote: "EvexAccountを使ってリモートオプションを利用してください。" + pleaseLoginContinue: "続行するにはEvexAccountでサインインしてください。" + errorStart: "EvexAccount認可の開始に失敗しました。" + errorComplete: "EvexAccount認可の完了に失敗しました。" uploading: "アップロード中" save: "保存" users: "ユーザー" diff --git a/packages/backend/package.json b/packages/backend/package.json index 921e89eff97..aad9fe23693 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -189,6 +189,7 @@ "@types/content-disposition": "0.5.9", "@types/fluent-ffmpeg": "2.1.28", "@types/http-link-header": "1.0.7", + "@types/ioredis": "5.0.0", "@types/jest": "29.5.14", "@types/jsonld": "1.5.15", "@types/mime-types": "3.0.1", diff --git a/packages/backend/src/server/EvexAccountService.ts b/packages/backend/src/server/EvexAccountService.ts new file mode 100644 index 00000000000..e1f9d993a41 --- /dev/null +++ b/packages/backend/src/server/EvexAccountService.ts @@ -0,0 +1,326 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createHash } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { MiUser } from '@/models/User.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { SignupService } from '@/core/SignupService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { bindThis } from '@/decorators.js'; +import type { Redis } from 'ioredis'; + +type EvexAccountUserInfo = { + sub: string; + name?: string; + picture?: string; + updated_at?: string; + email?: string; + email_verified?: boolean; + discord_id?: string | null; + discord_roles?: Array<{ + id: string; + name: string; + color: number; + position: number; + }> | null; +}; + +type PendingState = { + codeVerifier: string; + createdAt: number; +}; + +type EvexAccountCompleteResult = { + id: string; + token: string; + username: string; +}; + +@Injectable() +export class EvexAccountService { + private readonly issuer: string; + private readonly authorizationEndpoint: string; + private readonly tokenEndpoint: string; + private readonly userInfoEndpoint: string; + private readonly clientId: string | undefined; + private readonly clientSecret: string | undefined; + private readonly redirectUri: string; + private readonly statePrefix = 'evex-account:state:'; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redis: Redis, + + private httpRequestService: HttpRequestService, + private signupService: SignupService, + private userEntityService: UserEntityService, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + ) { + this.issuer = 'https://account.evex.land'; + this.authorizationEndpoint = process.env.EVEXACCOUNT_AUTHORIZATION_ENDPOINT ?? new URL('/api/oauth/authorize', this.issuer).toString(); + this.tokenEndpoint = process.env.EVEXACCOUNT_TOKEN_ENDPOINT ?? new URL('/api/oauth/token', this.issuer).toString(); + this.userInfoEndpoint = process.env.EVEXACCOUNT_USERINFO_ENDPOINT ?? new URL('/api/oauth/userinfo', this.issuer).toString(); + this.clientId = process.env.EVEXACCOUNT_CLIENT_ID; + this.clientSecret = process.env.EVEXACCOUNT_CLIENT_SECRET; + this.redirectUri = new URL('/callback', this.config.url).toString(); + } + + @bindThis + public async createAuthorizationUrl() { + if (!this.clientId) { + throw new Error('EVEXACCOUNT_CLIENT_ID is not configured'); + } + + const state = secureRndstr(64); + const codeVerifier = secureRndstr(96); + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url'); + + const stateData: PendingState = { + codeVerifier, + createdAt: Date.now(), + }; + + await this.redis.set( + `${this.statePrefix}${state}`, + JSON.stringify(stateData), + 'EX', + 60 * 10, + ); + + const authorizeUrl = new URL(this.authorizationEndpoint); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', this.clientId); + authorizeUrl.searchParams.set('redirect_uri', this.redirectUri); + authorizeUrl.searchParams.set('scope', 'openid profile email offline_access discord_id'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('code_challenge', codeChallenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + + return { + state, + authorizeUrl: authorizeUrl.toString(), + }; + } + + @bindThis + public async completeAuthorization(code: string, state: string): Promise { + if (!this.clientId) { + throw new Error('EVEXACCOUNT_CLIENT_ID is not configured'); + } + + const rawState = await this.redis.get(`${this.statePrefix}${state}`); + if (!rawState) { + throw new Error('Invalid or expired EvexAccount state'); + } + + await this.redis.del(`${this.statePrefix}${state}`); + + const pendingState = JSON.parse(rawState) as PendingState; + if (!pendingState.codeVerifier || typeof pendingState.codeVerifier !== 'string') { + throw new Error('Invalid EvexAccount state payload'); + } + + const tokenBody = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + client_id: this.clientId, + code_verifier: pendingState.codeVerifier, + }); + + if (this.clientSecret) { + tokenBody.set('client_secret', this.clientSecret); + } + + const tokenResponse = await this.httpRequestService.send(this.tokenEndpoint, { + method: 'POST', + body: tokenBody.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + timeout: 10_000, + }, { + throwErrorWhenResponseNotOk: true, + }); + + const tokenJson = await tokenResponse.json() as { + access_token: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + id_token?: string; + }; + + if (!tokenJson.access_token) { + throw new Error('EvexAccount did not return an access token'); + } + + const userInfoResponse = await this.httpRequestService.send(this.userInfoEndpoint, { + method: 'GET', + headers: { + Authorization: `Bearer ${tokenJson.access_token}`, + Accept: 'application/json', + }, + timeout: 10_000, + }, { + throwErrorWhenResponseNotOk: true, + }); + + const userInfo = await userInfoResponse.json() as EvexAccountUserInfo; + if (!userInfo.sub) { + throw new Error('EvexAccount userinfo did not include sub'); + } + + const { account, secret } = await this.findOrCreateLocalUser(userInfo); + const me = await this.userEntityService.pack(account, account, { + schema: 'MeDetailed', + includeSecrets: true, + }); + + return { + ...me, + token: secret, + }; + } + + private async findOrCreateLocalUser(userInfo: EvexAccountUserInfo): Promise<{ account: MiUser; secret: string; }> { + const linkedProfile = await this.userProfilesRepository.createQueryBuilder('profile') + .innerJoinAndSelect('profile.user', 'user') + .where("(profile.\"clientData\" -> 'evexAccount' ->> 'sub') = :sub", { sub: userInfo.sub }) + .getOne(); + + if (linkedProfile?.user) { + const account = linkedProfile.user; + await this.updateLinkedProfile(account.id, userInfo, linkedProfile); + await this.usersRepository.update(account.id, { + name: userInfo.name?.slice(0, 128) ?? account.name, + }); + return { + account, + secret: account.token!, + }; + } + + const password = secureRndstr(32); + const usernameCandidates = this.buildUsernameCandidates(userInfo); + let createdAccount: MiUser | null = null; + let secret: string | null = null; + + for (const username of usernameCandidates) { + try { + const result = await this.signupService.signup({ + username, + password, + }); + createdAccount = result.account; + secret = result.secret; + break; + } catch (err) { + const message = typeof err === 'string' ? err : (err as Error).message; + if (![ + 'INVALID_USERNAME', + 'DUPLICATED_USERNAME', + 'USED_USERNAME', + 'DENIED_USERNAME', + ].some(x => message.includes(x))) { + throw err; + } + } + } + + if (createdAccount == null || secret == null) { + throw new Error('Failed to derive a unique username from EvexAccount profile'); + } + + await this.updateLinkedProfile(createdAccount.id, userInfo); + await this.usersRepository.update(createdAccount.id, { + name: userInfo.name?.slice(0, 128) ?? createdAccount.name, + }); + + return { + account: createdAccount, + secret, + }; + } + + private async updateLinkedProfile(accountId: string, userInfo: EvexAccountUserInfo, currentProfile?: MiUserProfile) { + const profile = currentProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: accountId }); + const nextClientData = { + ...(profile.clientData ?? {}), + evexAccount: { + sub: userInfo.sub, + email: userInfo.email ?? null, + name: userInfo.name ?? null, + picture: userInfo.picture ?? null, + updatedAt: userInfo.updated_at ?? null, + }, + }; + + profile.email = userInfo.email ?? null; + profile.emailVerified = userInfo.email_verified === true; + profile.clientData = nextClientData; + await this.userProfilesRepository.save(profile); + } + + private buildUsernameCandidates(userInfo: EvexAccountUserInfo): string[] { + const candidates = new Set(); + const shortHash = createHash('sha256').update(userInfo.sub).digest('hex').slice(0, 10); + + const rawSources = [ + userInfo.email?.split('@')[0], + userInfo.name, + `evex_${shortHash}`, + ]; + + for (const source of rawSources) { + const normalized = this.normalizeUsername(source); + if (normalized) { + candidates.add(normalized); + } + } + + const generated = [...candidates]; + for (const base of generated) { + for (let i = 1; i <= 9; i++) { + candidates.add(this.truncateUsername(`${base}${i}`)); + } + } + + return [...candidates]; + } + + private normalizeUsername(source: string | undefined): string | null { + if (!source) return null; + + const normalized = source + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') + .replace(/_+/g, '_'); + + if (!normalized) return null; + return this.truncateUsername(normalized); + } + + private truncateUsername(username: string): string { + return username.slice(0, 20); + } +} diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index e228d511037..473d71679a1 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -29,6 +29,7 @@ import { FeedService } from './web/FeedService.js'; import { UrlPreviewService } from './web/UrlPreviewService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; +import { EvexAccountService } from './EvexAccountService.js'; import MainStreamConnection from '@/server/api/stream/Connection.js'; import { MainChannel } from './api/stream/channels/main.js'; @@ -102,6 +103,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j NoteStreamingHidingService, OpenApiServerService, OAuth2ProviderService, + EvexAccountService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 9cfb2f0ac0a..7bf2b772719 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -10,6 +10,7 @@ import * as endpointsObject from './endpoint-list.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import type { Provider } from '@nestjs/common'; +import { EvexAccountService } from '../EvexAccountService.js'; const endpoints = Object.entries(endpointsObject); const endpointProviders = endpoints.map(([path, endpoint]): Provider => ({ provide: `ep:${path}`, useClass: endpoint.default })); @@ -19,11 +20,13 @@ const endpointProviders = endpoints.map(([path, endpoint]): Provider => ({ provi CoreModule, ], providers: [ + EvexAccountService, GetterService, ApiLoggerService, ...endpointProviders, ], exports: [ + EvexAccountService, ...endpointProviders, ], }) diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 6679005c3c4..a63b7ebf00f 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -194,6 +194,8 @@ export * as 'emoji' from './endpoints/emoji.js'; export * as 'emojis' from './endpoints/emojis.js'; export * as 'endpoint' from './endpoints/endpoint.js'; export * as 'endpoints' from './endpoints/endpoints.js'; +export * as 'evex-account/complete' from './endpoints/evex-account/complete.js'; +export * as 'evex-account/start' from './endpoints/evex-account/start.js'; export * as 'export-custom-emojis' from './endpoints/export-custom-emojis.js'; export * as 'federation/followers' from './endpoints/federation/followers.js'; export * as 'federation/following' from './endpoints/federation/following.js'; diff --git a/packages/backend/src/server/api/endpoints/evex-account/complete.ts b/packages/backend/src/server/api/endpoints/evex-account/complete.ts new file mode 100644 index 00000000000..2bdd0e07568 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/evex-account/complete.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EvexAccountService } from '@/server/EvexAccountService.js'; + +export const meta = { + tags: ['auth'], + requireCredential: false, +} as const; + +export const paramDef = { + type: 'object', + properties: { + code: { type: 'string' }, + state: { type: 'string' }, + }, + required: ['code', 'state'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private evexAccountService: EvexAccountService, + ) { + super(meta, paramDef, async (ps) => { + return await this.evexAccountService.completeAuthorization(ps.code, ps.state); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/evex-account/start.ts b/packages/backend/src/server/api/endpoints/evex-account/start.ts new file mode 100644 index 00000000000..f5094db9cf8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/evex-account/start.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EvexAccountService } from '@/server/EvexAccountService.js'; + +export const meta = { + tags: ['auth'], + requireCredential: false, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private evexAccountService: EvexAccountService, + ) { + super(meta, paramDef, async () => { + return await this.evexAccountService.createAuthorizationUrl(); + }); + } +} diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 111a4abbfdf..f175e8f3bbc 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -16,7 +16,7 @@ import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; import { subBoot } from '@/boot/sub-boot.js'; -const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/verify-email', '/install-extensions']; +const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/callback', '/signup-complete', '/verify-email', '/install-extensions']; if (subBootPaths.some(i => window.location.pathname === i || window.location.pathname.startsWith(i + '/'))) { subBoot(); diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index b2f0f4ddcf1..5ab293c0802 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -5,6 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only