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 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));