mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Full rework of account management
Complete rewrite of the account management and related systems. Makes the architecture more modular, allowing for easier extensions and configurations.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import type { Adapter, AdapterPayload } from '../../../../templates/types/oidc-provider';
|
||||
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||
import type { AdapterFactory } from '../../storage/AdapterFactory';
|
||||
import { PassthroughAdapterFactory, PassthroughAdapter } from '../../storage/PassthroughAdapterFactory';
|
||||
import type { AccountStore } from '../account/util/AccountStore';
|
||||
import type { ClientCredentialsStore } from './util/ClientCredentialsStore';
|
||||
|
||||
/**
|
||||
* A {@link PassthroughAdapter} that overrides the `find` function
|
||||
* by checking if there are stored client credentials for the given ID
|
||||
* if no payload is found in the source.
|
||||
*/
|
||||
export class ClientCredentialsAdapter extends PassthroughAdapter {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly clientCredentialsStore: ClientCredentialsStore;
|
||||
|
||||
public constructor(name: string, source: Adapter, accountStore: AccountStore,
|
||||
clientCredentialsStore: ClientCredentialsStore) {
|
||||
super(name, source);
|
||||
this.accountStore = accountStore;
|
||||
this.clientCredentialsStore = clientCredentialsStore;
|
||||
}
|
||||
|
||||
public async find(id: string): Promise<AdapterPayload | void | undefined> {
|
||||
let payload = await this.source.find(id);
|
||||
|
||||
if (!payload && this.name === 'Client') {
|
||||
const credentials = await this.clientCredentialsStore.get(id);
|
||||
if (credentials) {
|
||||
// Make sure the WebID is still linked to the account.
|
||||
// Unlinking a WebID does not necessarily delete the corresponding credential tokens.
|
||||
const account = await this.accountStore.get(credentials.accountId);
|
||||
if (!account) {
|
||||
this.logger.error(`Storage contains credentials ${id} with unknown account ID ${credentials.accountId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account.webIds[credentials.webId]) {
|
||||
this.logger.warn(
|
||||
`Client credentials token ${id} contains WebID that is no longer linked to the account. Removing...`,
|
||||
);
|
||||
await this.clientCredentialsStore.delete(id, account);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Authenticating as ${credentials.webId} using client credentials`);
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
payload = {
|
||||
client_id: id,
|
||||
client_secret: credentials.secret,
|
||||
grant_types: [ 'client_credentials' ],
|
||||
redirect_uris: [],
|
||||
response_types: [],
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientCredentialsAdapterFactory extends PassthroughAdapterFactory {
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly clientCredentialsStore: ClientCredentialsStore;
|
||||
|
||||
public constructor(source: AdapterFactory, accountStore: AccountStore,
|
||||
clientCredentialsStore: ClientCredentialsStore) {
|
||||
super(source);
|
||||
this.accountStore = accountStore;
|
||||
this.clientCredentialsStore = clientCredentialsStore;
|
||||
}
|
||||
|
||||
public createStorageAdapter(name: string): Adapter {
|
||||
const adapter = this.source.createStorageAdapter(name);
|
||||
return new ClientCredentialsAdapter(name, adapter, this.accountStore, this.clientCredentialsStore);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||
import { InternalServerError } from '../../../util/errors/InternalServerError';
|
||||
import type { AccountStore } from '../account/util/AccountStore';
|
||||
import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil';
|
||||
import type { JsonRepresentation } from '../InteractionUtil';
|
||||
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
|
||||
import { JsonInteractionHandler } from '../JsonInteractionHandler';
|
||||
import type { ClientCredentialsStore } from './util/ClientCredentialsStore';
|
||||
|
||||
type OutType = {
|
||||
id: string;
|
||||
webId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides a view on a client credentials token, indicating the token identifier and its associated WebID.
|
||||
*/
|
||||
export class ClientCredentialsDetailsHandler extends JsonInteractionHandler<OutType> {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly clientCredentialsStore: ClientCredentialsStore;
|
||||
|
||||
public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) {
|
||||
super();
|
||||
this.accountStore = accountStore;
|
||||
this.clientCredentialsStore = clientCredentialsStore;
|
||||
}
|
||||
|
||||
public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise<JsonRepresentation<OutType>> {
|
||||
const account = await getRequiredAccount(this.accountStore, accountId);
|
||||
|
||||
const id = ensureResource(account.clientCredentials, target.path);
|
||||
|
||||
const credentials = await this.clientCredentialsStore.get(id);
|
||||
if (!credentials) {
|
||||
this.logger.error(
|
||||
`Data inconsistency between account and credentials data for account ${account.id} and token ${id}.`,
|
||||
);
|
||||
throw new InternalServerError('Data inconsistency between account and client credentials data.');
|
||||
}
|
||||
|
||||
return { json: {
|
||||
id,
|
||||
webId: credentials.webId,
|
||||
}};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { v4 } from 'uuid';
|
||||
import { object, string } from 'yup';
|
||||
import { sanitizeUrlPart } from '../../../util/StringUtil';
|
||||
import type { AccountStore } from '../account/util/AccountStore';
|
||||
import { getRequiredAccount } from '../account/util/AccountUtil';
|
||||
import type { JsonRepresentation } from '../InteractionUtil';
|
||||
import { JsonInteractionHandler } from '../JsonInteractionHandler';
|
||||
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
|
||||
import type { JsonView } from '../JsonView';
|
||||
import { parseSchema, validateWithError } from '../YupUtil';
|
||||
import type { ClientCredentialsStore } from './util/ClientCredentialsStore';
|
||||
|
||||
const inSchema = object({
|
||||
name: string().trim().optional(),
|
||||
webId: string().trim().required(),
|
||||
});
|
||||
|
||||
type OutType = {
|
||||
id: string;
|
||||
secret: string;
|
||||
resource: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the creation of client credential tokens.
|
||||
*/
|
||||
export class CreateClientCredentialsHandler extends JsonInteractionHandler<OutType> implements JsonView {
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly clientCredentialsStore: ClientCredentialsStore;
|
||||
|
||||
public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) {
|
||||
super();
|
||||
this.accountStore = accountStore;
|
||||
this.clientCredentialsStore = clientCredentialsStore;
|
||||
}
|
||||
|
||||
public async getView(): Promise<JsonRepresentation> {
|
||||
return { json: parseSchema(inSchema) };
|
||||
}
|
||||
|
||||
public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise<JsonRepresentation<OutType>> {
|
||||
const account = await getRequiredAccount(this.accountStore, accountId);
|
||||
|
||||
const { name, webId } = await validateWithError(inSchema, json);
|
||||
const cleanedName = name ? sanitizeUrlPart(name.trim()) : '';
|
||||
const id = `${cleanedName}_${v4()}`;
|
||||
|
||||
const { secret, resource } = await this.clientCredentialsStore.add(id, webId, account);
|
||||
|
||||
return { json: { id, secret, resource }};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { EmptyObject } from '../../../util/map/MapUtil';
|
||||
import type { AccountStore } from '../account/util/AccountStore';
|
||||
import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil';
|
||||
import type { JsonRepresentation } from '../InteractionUtil';
|
||||
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
|
||||
import { JsonInteractionHandler } from '../JsonInteractionHandler';
|
||||
import type { ClientCredentialsStore } from './util/ClientCredentialsStore';
|
||||
|
||||
/**
|
||||
* Handles the deletion of client credentials tokens.
|
||||
*/
|
||||
export class DeleteClientCredentialsHandler extends JsonInteractionHandler<EmptyObject> {
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly clientCredentialsStore: ClientCredentialsStore;
|
||||
|
||||
public constructor(accountStore: AccountStore, clientCredentialsStore: ClientCredentialsStore) {
|
||||
super();
|
||||
this.accountStore = accountStore;
|
||||
this.clientCredentialsStore = clientCredentialsStore;
|
||||
}
|
||||
|
||||
public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise<JsonRepresentation<EmptyObject>> {
|
||||
const account = await getRequiredAccount(this.accountStore, accountId);
|
||||
|
||||
const id = ensureResource(account.clientCredentials, target.path);
|
||||
|
||||
// This also deletes it from the account
|
||||
await this.clientCredentialsStore.delete(id, account);
|
||||
|
||||
return { json: {}};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getLoggerFor } from '../../../../logging/LogUtil';
|
||||
import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage';
|
||||
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
|
||||
import type { Account } from '../../account/util/Account';
|
||||
import type { AccountStore } from '../../account/util/AccountStore';
|
||||
import { safeUpdate } from '../../account/util/AccountUtil';
|
||||
import type { ClientCredentialsIdRoute } from './ClientCredentialsIdRoute';
|
||||
import type { ClientCredentials, ClientCredentialsStore } from './ClientCredentialsStore';
|
||||
|
||||
/**
|
||||
* A {@link ClientCredentialsStore} that uses a {@link KeyValueStorage} for storing the tokens.
|
||||
*/
|
||||
export class BaseClientCredentialsStore implements ClientCredentialsStore {
|
||||
private readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly clientCredentialsRoute: ClientCredentialsIdRoute;
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly storage: KeyValueStorage<string, ClientCredentials>;
|
||||
|
||||
public constructor(clientCredentialsRoute: ClientCredentialsIdRoute, accountStore: AccountStore,
|
||||
storage: KeyValueStorage<string, ClientCredentials>) {
|
||||
this.clientCredentialsRoute = clientCredentialsRoute;
|
||||
this.accountStore = accountStore;
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<ClientCredentials | undefined> {
|
||||
return this.storage.get(id);
|
||||
}
|
||||
|
||||
public async add(id: string, webId: string, account: Account): Promise<{ secret: string; resource: string }> {
|
||||
if (typeof account.webIds[webId] !== 'string') {
|
||||
this.logger.warn(`Trying to create token for ${webId} which does not belong to account ${account.id}`);
|
||||
throw new BadRequestHttpError('WebID does not belong to this account.');
|
||||
}
|
||||
|
||||
const secret = randomBytes(64).toString('hex');
|
||||
const resource = this.clientCredentialsRoute.getPath({ accountId: account.id, clientCredentialsId: id });
|
||||
|
||||
account.clientCredentials[id] = resource;
|
||||
await safeUpdate(account,
|
||||
this.accountStore,
|
||||
(): Promise<any> => this.storage.set(id, { accountId: account.id, secret, webId }));
|
||||
|
||||
this.logger.debug(`Created client credentials token ${id} for WebID ${webId} and account ${account.id}`);
|
||||
|
||||
return { secret, resource };
|
||||
}
|
||||
|
||||
public async delete(id: string, account: Account): Promise<void> {
|
||||
const link = account.clientCredentials[id];
|
||||
|
||||
if (link) {
|
||||
delete account.clientCredentials[id];
|
||||
await safeUpdate(account,
|
||||
this.accountStore,
|
||||
(): Promise<any> => this.storage.delete(id));
|
||||
|
||||
this.logger.debug(`Deleted client credentials token ${id} for account ${account.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AccountIdKey, AccountIdRoute } from '../../account/AccountIdRoute';
|
||||
import { IdInteractionRoute } from '../../routing/IdInteractionRoute';
|
||||
import type { ExtendedRoute } from '../../routing/InteractionRoute';
|
||||
|
||||
export type CredentialsIdKey = 'clientCredentialsId';
|
||||
|
||||
/**
|
||||
* An {@link AccountIdRoute} that also includes a credentials identifier.
|
||||
*/
|
||||
export type ClientCredentialsIdRoute = ExtendedRoute<AccountIdRoute, CredentialsIdKey>;
|
||||
|
||||
/**
|
||||
* Implementation of an {@link ClientCredentialsIdRoute}
|
||||
* that adds the identifier relative to a base {@link AccountIdRoute}.
|
||||
*/
|
||||
export class BaseClientCredentialsIdRoute extends IdInteractionRoute<AccountIdKey, CredentialsIdKey> {
|
||||
public constructor(base: AccountIdRoute) {
|
||||
super(base, 'clientCredentialsId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Account } from '../../account/util/Account';
|
||||
|
||||
/**
|
||||
* A client credentials token.
|
||||
* If at some point the WebID is no longer registered to the account stored in this token,
|
||||
* the token should be invalidated.
|
||||
*/
|
||||
export interface ClientCredentials {
|
||||
/**
|
||||
* The identifier of the account that created the token.
|
||||
*/
|
||||
accountId: string;
|
||||
/**
|
||||
* The secret of the token.
|
||||
*/
|
||||
secret: string;
|
||||
/**
|
||||
* The WebID users will be identified as after using the token.
|
||||
*/
|
||||
webId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores and creates {@link ClientCredentials}.
|
||||
*/
|
||||
export interface ClientCredentialsStore {
|
||||
/**
|
||||
* Find the {@link ClientCredentials} with the given label. Undefined if there is no match.
|
||||
* @param label - Label of the credentials.
|
||||
*/
|
||||
get: (label: string) => Promise<ClientCredentials | undefined>;
|
||||
/**
|
||||
* Creates new {@link ClientCredentials} and adds a reference to the account.
|
||||
* Will error if the WebID is not registered to the account.
|
||||
*
|
||||
* @param label - Identifier to use for the new credentials.
|
||||
* @param webId - WebID to identify as when using this token.
|
||||
* @param account - Account that is associated with this token.
|
||||
*/
|
||||
add: (label: string, webId: string, account: Account) => Promise<{ secret: string; resource: string }>;
|
||||
/**
|
||||
* Deletes the token with the given identifier and removes the reference from the account.
|
||||
* @param label - Identifier of the token.
|
||||
* @param account - Account this token belongs to.
|
||||
*/
|
||||
delete: (label: string, account: Account) => Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user