forked from misskey-dev/misskey
-
Notifications
You must be signed in to change notification settings - Fork 1
EvexAccount support #4
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
Open
akku1139
wants to merge
8
commits into
develop
Choose a base branch
from
feat/evexaccount
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a925572
EvexAccount関連の変更 (#1)
Povo-43 69e3b5f
初回セットアップを戻す
akku1139 1060453
設定例の改善
akku1139 b789198
linterの指摘に従う
akku1139 45467cf
余計な変更を戻す
akku1139 f2beedd
misskey-jsを再生成
akku1139 de1dc8c
ログイン、登録フォームを元の方式も戻す (by Gemini 3 Flash)
akku1139 0645397
fmt
akku1139 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<EvexAccountCompleteResult> { | ||
| 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<string>(); | ||
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
勝手にユーザー名決定するのどうなの?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
いいんじゃない