fix(backend): OAuth2認証ができない問題を修正 (MisskeyIO#404)

This commit is contained in:
まっちゃとーにゅ 2024-02-02 21:56:00 +09:00 committed by GitHub
parent 1d13e66270
commit 599c610d61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -9,7 +9,14 @@ import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import httpLinkHeader from 'http-link-header'; import httpLinkHeader from 'http-link-header';
import ipaddr from 'ipaddr.js'; 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 oauth2Pkce from 'oauth2orize-pkce';
import fastifyCors from '@fastify/cors'; import fastifyCors from '@fastify/cors';
import fastifyView from '@fastify/view'; import fastifyView from '@fastify/view';
@ -28,12 +35,12 @@ import type { AccessTokensRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import type { MiLocalUser } from '@/models/User.js'; import type { MiLocalUser } from '@/models/User.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import type { ServerResponse } from 'node:http'; import type { ServerResponse } from 'node:http';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import * as Redis from 'ioredis';
// TODO: Consider migrating to @node-oauth/oauth2-server once // TODO: Consider migrating to @node-oauth/oauth2-server once
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out. // 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() * 2. oauth/decision will call load() to retrieve the parameters and then remove()
*/ */
class OAuth2Store { 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; const { transaction_id } = req.body;
if (!transaction_id) { if (!transaction_id) {
cb(new AuthorizationError('Missing transaction ID', 'invalid_request')); cb(new AuthorizationError('Missing transaction ID', 'invalid_request'));
return; return;
} }
const loaded = this.#cache.get(transaction_id); const loaded = await this.redisClient.get(`oauth2:transaction:${transaction_id}`);
if (!loaded) { if (!loaded) {
cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied')); cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied'));
return; 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); 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); cb(null, transactionId);
} }
remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void { remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void {
this.#cache.delete(tid); this.redisClient.del(`oauth2:transaction:${tid}`);
cb(); cb();
} }
} }
@Injectable() @Injectable()
export class OAuth2ProviderService { export class OAuth2ProviderService {
#server = oauth2orize.createServer({ #server: OAuth2Server;
store: new OAuth2Store(),
});
#logger: Logger; #logger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
private httpRequestService: HttpRequestService, @Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.accessTokensRepository) @Inject(DI.accessTokensRepository)
accessTokensRepository: AccessTokensRepository, private accessTokensRepository: AccessTokensRepository,
idService: IdService,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private idService: IdService,
private cacheService: CacheService, private cacheService: CacheService,
loggerService: LoggerService, private loggerService: LoggerService,
private httpRequestService: HttpRequestService,
) { ) {
this.#logger = loggerService.getLogger('oauth'); this.#logger = loggerService.getLogger('oauth');
this.#server = oauth2orize.createServer({
const grantCodeCache = new MemoryKVCache<{ store: new OAuth2Store(redisClient),
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
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
// "Authorization servers MUST support PKCE [RFC7636]." // "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}]`); 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); const code = secureRndstr(128);
grantCodeCache.set(code, { await this.redisClient.set(`oauth2:authorization:${code}`, JSON.stringify({
clientId: client.id, clientId: client.id,
userId: user.id, userId: user.id,
redirectUri, redirectUri,
codeChallenge: (areq as OAuthParsedRequest).codeChallenge, codeChallenge: (areq as OAuthParsedRequest).codeChallenge,
scopes: areq.scope, scopes: areq.scope,
}); }), 'EX', 60 * 5);
return [code]; return [code];
})().then(args => done(null, ...args), err => done(err)); })().then(args => done(null, ...args), err => done(err));
})); }));
this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => { this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => {
(async (): Promise<OmitFirstElement<Parameters<typeof done>> | undefined> => { (async (): Promise<OmitFirstElement<Parameters<typeof done>> | undefined> => {
this.#logger.info('Checking the received authorization code for the exchange'); this.#logger.info('Checking the received authorization code for the exchange');
const granted = grantCodeCache.get(code); const grantedJson = await this.redisClient.get(`oauth2:authorization:${code}`);
if (!granted) { if (!grantedJson) {
return; return;
} }
const granted = JSON.parse(grantedJson);
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2 // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2
// "If an authorization code is used more than once, the authorization server // "If an authorization code is used more than once, the authorization server
// MUST deny the request and SHOULD revoke (when possible) all tokens // MUST deny the request and SHOULD revoke (when possible) all tokens
// previously issued based on that authorization code." // 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.`); this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`);
grantCodeCache.delete(code); await this.redisClient.set(`oauth2:authorization:${code}:state`, 'revoked', 'EX', 60 * 5);
granted.revoked = true;
if (granted.grantedToken) { if (granted.grantedToken) {
await accessTokensRepository.delete({ token: granted.grantedToken }); await accessTokensRepository.delete({ token: granted.grantedToken });
} }
return; 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 // https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3
if (body.client_id !== granted.clientId) return; if (body.client_id !== granted.clientId) return;
@ -334,7 +336,8 @@ export class OAuth2ProviderService {
permission: granted.scopes, 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.'); this.#logger.info('Canceling the token as the authorization code was revoked in parallel during the process.');
await accessTokensRepository.delete({ token: accessToken }); await accessTokensRepository.delete({ token: accessToken });
return; return;
@ -342,6 +345,7 @@ export class OAuth2ProviderService {
granted.grantedToken = accessToken; granted.grantedToken = accessToken;
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`); 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(' ') }]; return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
})().then(args => done(null, ...args ?? []), err => done(err)); })().then(args => done(null, ...args ?? []), err => done(err));