A key can be limited to IP addresses or CIDR networks (e.g. the Wabenhain server); a stolen key is useless from anywhere else. Requests from other addresses get 403 "not allowed for this IP" and a log line. The admin UI asks for the addresses when creating a key, warns when a key works from anywhere, shows the last source address and can take it over into the allowlist - changing the list needs no new key (PATCH /api-keys/:id). Also: a key can only carry scopes its creator holds. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
85 lines
2.7 KiB
TypeScript
85 lines
2.7 KiB
TypeScript
import {
|
|
ExecutionContext,
|
|
Injectable,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
|
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
|
import { AuthenticatedUser } from '../types/authenticated-user';
|
|
import {
|
|
ApiKeyPrincipal,
|
|
ApiKeysService,
|
|
extractApiKeyToken,
|
|
} from '../../api-keys/api-keys.service';
|
|
|
|
interface RequestWithUser {
|
|
headers: { authorization?: string };
|
|
ip?: string;
|
|
user?: AuthenticatedUser;
|
|
apiKey?: ApiKeyPrincipal;
|
|
}
|
|
|
|
/**
|
|
* Enforces User.isActive and token freshness (issued after the last
|
|
* password change) on every authenticated request, not just ones gated by
|
|
* @RequirePermission. Without this, a deactivated user's still-valid access
|
|
* token - or one stolen before a password change - would keep working on
|
|
* any route that carries no permission decorator until the token's own
|
|
* expiry (up to JWT_ACCESS_EXPIRES_IN). isTokenValid shares
|
|
* PermissionsGuard's cache, so this check costs no extra query beyond what
|
|
* permission resolution already does.
|
|
*/
|
|
@Injectable()
|
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
|
constructor(
|
|
private readonly reflector: Reflector,
|
|
private readonly permissionResolver: PermissionResolverService,
|
|
private readonly apiKeys: ApiKeysService,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
context.getHandler(),
|
|
context.getClass(),
|
|
]);
|
|
if (isPublic) {
|
|
return true;
|
|
}
|
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
|
|
|
// An API key ("Bearer ack_…") authenticates a machine, not a user: no
|
|
// request.user is set, so it can never pass a user-only route, and
|
|
// PermissionsGuard checks its scopes instead of a role.
|
|
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
|
|
if (apiKeyToken !== null) {
|
|
// request.ip honours TRUST_PROXY (see app.config.ts).
|
|
const principal = await this.apiKeys.authenticate(
|
|
apiKeyToken,
|
|
request.ip,
|
|
);
|
|
if (!principal) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
request.apiKey = principal;
|
|
return true;
|
|
}
|
|
|
|
const authenticated = (await super.canActivate(context)) as boolean;
|
|
if (!authenticated) {
|
|
return false;
|
|
}
|
|
const user = request.user;
|
|
if (
|
|
!user ||
|
|
!(await this.permissionResolver.isTokenValid(user.id, user.tokenIssuedAt))
|
|
) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
return true;
|
|
}
|
|
}
|