fix(backend): OAuth2認証ができない問題を修正 (MisskeyIO#404)
This commit is contained in:
		
							parent
							
								
									1d13e66270
								
							
						
					
					
						commit
						599c610d61
					
				|  | @ -9,7 +9,14 @@ import { Inject, Injectable } from '@nestjs/common'; | |||
| import { JSDOM } from 'jsdom'; | ||||
| import httpLinkHeader from 'http-link-header'; | ||||
| import ipaddr from 'ipaddr.js'; | ||||
| import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize'; | ||||
| import oauth2orize, { | ||||
| 	type OAuth2, | ||||
| 	OAuth2Server, | ||||
| 	MiddlewareRequest, | ||||
| 	OAuth2Req, | ||||
| 	ValidateFunctionArity2, | ||||
| 	AuthorizationError, | ||||
| } from 'oauth2orize'; | ||||
| import oauth2Pkce from 'oauth2orize-pkce'; | ||||
| import fastifyCors from '@fastify/cors'; | ||||
| import fastifyView from '@fastify/view'; | ||||
|  | @ -28,12 +35,12 @@ import type { AccessTokensRepository, UsersRepository } from '@/models/_.js'; | |||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import type { MiLocalUser } from '@/models/User.js'; | ||||
| import { MemoryKVCache } from '@/misc/cache.js'; | ||||
| import { LoggerService } from '@/core/LoggerService.js'; | ||||
| import Logger from '@/logger.js'; | ||||
| import { StatusError } from '@/misc/status-error.js'; | ||||
| import type { ServerResponse } from 'node:http'; | ||||
| import type { FastifyInstance } from 'fastify'; | ||||
| import * as Redis from 'ioredis'; | ||||
| 
 | ||||
| // TODO: Consider migrating to @node-oauth/oauth2-server once
 | ||||
| // https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
 | ||||
|  | @ -196,67 +203,61 @@ function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { | |||
|  * 2. oauth/decision will call load() to retrieve the parameters and then remove() | ||||
|  */ | ||||
| class OAuth2Store { | ||||
| 	#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // expires after 5min
 | ||||
| 	constructor( | ||||
| 		private redisClient: Redis.Redis, | ||||
| 	) { | ||||
| 	} | ||||
| 
 | ||||
| 	load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): void { | ||||
| 	async load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): Promise<void> { | ||||
| 		const { transaction_id } = req.body; | ||||
| 		if (!transaction_id) { | ||||
| 			cb(new AuthorizationError('Missing transaction ID', 'invalid_request')); | ||||
| 			return; | ||||
| 		} | ||||
| 		const loaded = this.#cache.get(transaction_id); | ||||
| 		const loaded = await this.redisClient.get(`oauth2:transaction:${transaction_id}`); | ||||
| 		if (!loaded) { | ||||
| 			cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied')); | ||||
| 			return; | ||||
| 		} | ||||
| 		cb(null, loaded); | ||||
| 		cb(null, JSON.parse(loaded) as OAuth2); | ||||
| 	} | ||||
| 
 | ||||
| 	store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void { | ||||
| 	async store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): Promise<void> { | ||||
| 		const transactionId = secureRndstr(128); | ||||
| 		this.#cache.set(transactionId, oauth2); | ||||
| 		await this.redisClient.set(`oauth2:transaction:${transactionId}`, JSON.stringify(oauth2), 'EX', 60 * 5); | ||||
| 		cb(null, transactionId); | ||||
| 	} | ||||
| 
 | ||||
| 	remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void { | ||||
| 		this.#cache.delete(tid); | ||||
| 		this.redisClient.del(`oauth2:transaction:${tid}`); | ||||
| 		cb(); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| @Injectable() | ||||
| export class OAuth2ProviderService { | ||||
| 	#server = oauth2orize.createServer({ | ||||
| 		store: new OAuth2Store(), | ||||
| 	}); | ||||
| 	#server: OAuth2Server; | ||||
| 	#logger: Logger; | ||||
| 
 | ||||
| 	constructor( | ||||
| 		@Inject(DI.config) | ||||
| 		private config: Config, | ||||
| 		private httpRequestService: HttpRequestService, | ||||
| 		@Inject(DI.redis) | ||||
| 		private redisClient: Redis.Redis, | ||||
| 		@Inject(DI.accessTokensRepository) | ||||
| 		accessTokensRepository: AccessTokensRepository, | ||||
| 		idService: IdService, | ||||
| 		private accessTokensRepository: AccessTokensRepository, | ||||
| 		@Inject(DI.usersRepository) | ||||
| 		private usersRepository: UsersRepository, | ||||
| 
 | ||||
| 		private idService: IdService, | ||||
| 		private cacheService: CacheService, | ||||
| 		loggerService: LoggerService, | ||||
| 		private loggerService: LoggerService, | ||||
| 		private httpRequestService: HttpRequestService, | ||||
| 	) { | ||||
| 		this.#logger = loggerService.getLogger('oauth'); | ||||
| 
 | ||||
| 		const grantCodeCache = new MemoryKVCache<{ | ||||
| 			clientId: string, | ||||
| 			userId: string, | ||||
| 			redirectUri: string, | ||||
| 			codeChallenge: string, | ||||
| 			scopes: string[], | ||||
| 
 | ||||
| 			// fields to prevent multiple code use
 | ||||
| 			grantedToken?: string, | ||||
| 			revoked?: boolean, | ||||
| 			used?: boolean, | ||||
| 		}>(1000 * 60 * 5); // expires after 5m
 | ||||
| 		this.#server = oauth2orize.createServer({ | ||||
| 			store: new OAuth2Store(redisClient), | ||||
| 		}); | ||||
| 
 | ||||
| 		// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
 | ||||
| 		// "Authorization servers MUST support PKCE [RFC7636]."
 | ||||
|  | @ -279,38 +280,39 @@ export class OAuth2ProviderService { | |||
| 				this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`); | ||||
| 
 | ||||
| 				const code = secureRndstr(128); | ||||
| 				grantCodeCache.set(code, { | ||||
| 				await this.redisClient.set(`oauth2:authorization:${code}`, JSON.stringify({ | ||||
| 					clientId: client.id, | ||||
| 					userId: user.id, | ||||
| 					redirectUri, | ||||
| 					codeChallenge: (areq as OAuthParsedRequest).codeChallenge, | ||||
| 					scopes: areq.scope, | ||||
| 				}); | ||||
| 				}), 'EX', 60 * 5); | ||||
| 				return [code]; | ||||
| 			})().then(args => done(null, ...args), err => done(err)); | ||||
| 		})); | ||||
| 		this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => { | ||||
| 			(async (): Promise<OmitFirstElement<Parameters<typeof done>> | undefined> => { | ||||
| 				this.#logger.info('Checking the received authorization code for the exchange'); | ||||
| 				const granted = grantCodeCache.get(code); | ||||
| 				if (!granted) { | ||||
| 				const grantedJson = await this.redisClient.get(`oauth2:authorization:${code}`); | ||||
| 				if (!grantedJson) { | ||||
| 					return; | ||||
| 				} | ||||
| 				const granted = JSON.parse(grantedJson); | ||||
| 
 | ||||
| 				// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2
 | ||||
| 				// "If an authorization code is used more than once, the authorization server
 | ||||
| 				// MUST deny the request and SHOULD revoke (when possible) all tokens
 | ||||
| 				// previously issued based on that authorization code."
 | ||||
| 				if (granted.used) { | ||||
| 				let grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`); | ||||
| 				if (grantedState !== null) { | ||||
| 					this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`); | ||||
| 					grantCodeCache.delete(code); | ||||
| 					granted.revoked = true; | ||||
| 					await this.redisClient.set(`oauth2:authorization:${code}:state`, 'revoked', 'EX', 60 * 5); | ||||
| 					if (granted.grantedToken) { | ||||
| 						await accessTokensRepository.delete({ token: granted.grantedToken }); | ||||
| 					} | ||||
| 					return; | ||||
| 				} | ||||
| 				granted.used = true; | ||||
| 				await this.redisClient.set(`oauth2:authorization:${code}:state`, 'used', 'EX', 60 * 5); | ||||
| 
 | ||||
| 				// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3
 | ||||
| 				if (body.client_id !== granted.clientId) return; | ||||
|  | @ -334,7 +336,8 @@ export class OAuth2ProviderService { | |||
| 					permission: granted.scopes, | ||||
| 				}); | ||||
| 
 | ||||
| 				if (granted.revoked) { | ||||
| 				grantedState = await this.redisClient.get(`oauth2:authorization:${code}:state`); | ||||
| 				if (grantedState === 'revoked') { | ||||
| 					this.#logger.info('Canceling the token as the authorization code was revoked in parallel during the process.'); | ||||
| 					await accessTokensRepository.delete({ token: accessToken }); | ||||
| 					return; | ||||
|  | @ -342,6 +345,7 @@ export class OAuth2ProviderService { | |||
| 
 | ||||
| 				granted.grantedToken = accessToken; | ||||
| 				this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); | ||||
| 				await this.redisClient.set(`oauth2:authorization:${code}`, JSON.stringify(granted), 'EX', 60 * 5); | ||||
| 
 | ||||
| 				return [accessToken, undefined, { scope: granted.scopes.join(' ') }]; | ||||
| 			})().then(args => done(null, ...args ?? []), err => done(err)); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue