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 { 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));
|
||||||
|
|
Loading…
Reference in a new issue