mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor(core): move verification record id to header (#6801)
This commit is contained in:
parent
640425414f
commit
859495b13b
14 changed files with 152 additions and 163 deletions
|
@ -1,4 +1,3 @@
|
||||||
import RequestError from '../errors/RequestError/index.js';
|
|
||||||
import { expirationTime } from '../queries/verification-records.js';
|
import { expirationTime } from '../queries/verification-records.js';
|
||||||
import {
|
import {
|
||||||
buildVerificationRecord,
|
buildVerificationRecord,
|
||||||
|
@ -43,34 +42,6 @@ const getVerificationRecordById = async ({
|
||||||
return buildVerificationRecord(libraries, queries, result.data);
|
return buildVerificationRecord(libraries, queries, result.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies the user sensitive permission by checking if the verification record is valid
|
|
||||||
* and associated with the user.
|
|
||||||
*/
|
|
||||||
export const verifyUserSensitivePermission = async ({
|
|
||||||
userId,
|
|
||||||
id,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
}: {
|
|
||||||
userId: string;
|
|
||||||
id: string;
|
|
||||||
queries: Queries;
|
|
||||||
libraries: Libraries;
|
|
||||||
}): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const record = await getVerificationRecordById({ id, queries, libraries, userId });
|
|
||||||
|
|
||||||
assertThat(record.isVerified, 'verification_record.not_found');
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof RequestError) {
|
|
||||||
throw new RequestError({ code: 'verification_record.permission_denied', status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a user verification record by its id and type.
|
* Builds a user verification record by its id and type.
|
||||||
* This is used to build a verification record for new identifier verifications,
|
* This is used to build a verification record for new identifier verifications,
|
||||||
|
|
1
packages/core/src/middleware/koa-auth/constants.ts
Normal file
1
packages/core/src/middleware/koa-auth/constants.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const verificationRecordIdHeader = 'logto-verification-id';
|
|
@ -16,6 +16,7 @@ import { type WithAuthContext, type TokenInfo } from './types.js';
|
||||||
import { extractBearerTokenFromHeaders, getAdminTenantTokenValidationSet } from './utils.js';
|
import { extractBearerTokenFromHeaders, getAdminTenantTokenValidationSet } from './utils.js';
|
||||||
|
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
|
export * from './constants.js';
|
||||||
|
|
||||||
export const verifyBearerTokenFromRequest = async (
|
export const verifyBearerTokenFromRequest = async (
|
||||||
envSet: EnvSet,
|
envSet: EnvSet,
|
||||||
|
|
|
@ -7,11 +7,15 @@ import Sinon from 'sinon';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import { MockTenant } from '../../test-utils/tenant.js';
|
||||||
|
|
||||||
import type { WithAuthContext } from './index.js';
|
import type { WithAuthContext } from './index.js';
|
||||||
|
|
||||||
const { jest } = import.meta;
|
const { jest } = import.meta;
|
||||||
|
|
||||||
const provider = new Provider('https://logto.test');
|
const provider = new Provider('https://logto.test');
|
||||||
|
const tenantContext = new MockTenant(provider);
|
||||||
|
|
||||||
const mockAccessToken = {
|
const mockAccessToken = {
|
||||||
accountId: 'fooUser',
|
accountId: 'fooUser',
|
||||||
clientId: 'fooClient',
|
clientId: 'fooClient',
|
||||||
|
@ -70,12 +74,19 @@ describe('koaOidcAuth middleware', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Sinon.stub(provider.AccessToken, 'find').resolves(mockAccessToken);
|
Sinon.stub(provider.AccessToken, 'find').resolves(mockAccessToken);
|
||||||
await koaOidcAuth(provider)(ctx, next);
|
await koaOidcAuth(tenantContext)(ctx, next);
|
||||||
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser', scopes: new Set(['openid']) });
|
expect(ctx.auth).toEqual({
|
||||||
|
type: 'user',
|
||||||
|
id: 'fooUser',
|
||||||
|
scopes: new Set(['openid']),
|
||||||
|
identityVerified: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expect to throw if authorization header is missing', async () => {
|
it('expect to throw if authorization header is missing', async () => {
|
||||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(authHeaderMissingError);
|
await expect(koaOidcAuth(tenantContext)(ctx, next)).rejects.toMatchError(
|
||||||
|
authHeaderMissingError
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expect to throw if authorization header token type not recognized ', async () => {
|
it('expect to throw if authorization header token type not recognized ', async () => {
|
||||||
|
@ -86,7 +97,9 @@ describe('koaOidcAuth middleware', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(tokenNotSupportedError);
|
await expect(koaOidcAuth(tenantContext)(ctx, next)).rejects.toMatchError(
|
||||||
|
tokenNotSupportedError
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expect to throw if access token is not found', async () => {
|
it('expect to throw if access token is not found', async () => {
|
||||||
|
@ -98,7 +111,7 @@ describe('koaOidcAuth middleware', () => {
|
||||||
};
|
};
|
||||||
Sinon.stub(provider.AccessToken, 'find').resolves();
|
Sinon.stub(provider.AccessToken, 'find').resolves();
|
||||||
|
|
||||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError);
|
await expect(koaOidcAuth(tenantContext)(ctx, next)).rejects.toMatchError(unauthorizedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expect to throw if sub is missing', async () => {
|
it('expect to throw if sub is missing', async () => {
|
||||||
|
@ -113,7 +126,7 @@ describe('koaOidcAuth middleware', () => {
|
||||||
accountId: undefined,
|
accountId: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError);
|
await expect(koaOidcAuth(tenantContext)(ctx, next)).rejects.toMatchError(unauthorizedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expect to throw if access token does not have openid scope', async () => {
|
it('expect to throw if access token does not have openid scope', async () => {
|
||||||
|
@ -128,6 +141,6 @@ describe('koaOidcAuth middleware', () => {
|
||||||
scopes: new Set(['foo']),
|
scopes: new Set(['foo']),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(forbiddenError);
|
await expect(koaOidcAuth(tenantContext)(ctx, next)).rejects.toMatchError(forbiddenError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,18 +1,58 @@
|
||||||
import type { MiddlewareType } from 'koa';
|
import type { MiddlewareType } from 'koa';
|
||||||
import type { IRouterParamContext } from 'koa-router';
|
import type { IRouterParamContext } from 'koa-router';
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import {
|
||||||
|
verificationRecordDataGuard,
|
||||||
|
buildVerificationRecord,
|
||||||
|
} from '#src/routes/experience/classes/verifications/index.js';
|
||||||
|
import type Libraries from '#src/tenants/Libraries.js';
|
||||||
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
import { verificationRecordIdHeader } from './constants.js';
|
||||||
import { type WithAuthContext } from './types.js';
|
import { type WithAuthContext } from './types.js';
|
||||||
import { extractBearerTokenFromHeaders } from './utils.js';
|
import { extractBearerTokenFromHeaders } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a verification record by its id.
|
||||||
|
* The `userId` is optional and is only used for user sensitive permission verifications.
|
||||||
|
*/
|
||||||
|
const getVerificationRecordResultById = async ({
|
||||||
|
id,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
queries: Queries;
|
||||||
|
libraries: Libraries;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
const record = await queries.verificationRecords.findActiveVerificationRecordById(id);
|
||||||
|
if (record?.userId !== userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = verificationRecordDataGuard.safeParse({
|
||||||
|
...record.data,
|
||||||
|
id: record.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = buildVerificationRecord(libraries, queries, result.data);
|
||||||
|
return instance.isVerified;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth middleware for OIDC opaque token
|
* Auth middleware for OIDC opaque token
|
||||||
*/
|
*/
|
||||||
export default function koaOidcAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
export default function koaOidcAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||||
provider: Provider
|
tenant: TenantContext
|
||||||
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
|
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
|
||||||
const authMiddleware: MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> = async (
|
const authMiddleware: MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> = async (
|
||||||
ctx,
|
ctx,
|
||||||
|
@ -20,7 +60,7 @@ export default function koaOidcAuth<StateT, ContextT extends IRouterParamContext
|
||||||
) => {
|
) => {
|
||||||
const { request } = ctx;
|
const { request } = ctx;
|
||||||
const accessTokenValue = extractBearerTokenFromHeaders(request.headers);
|
const accessTokenValue = extractBearerTokenFromHeaders(request.headers);
|
||||||
const accessToken = await provider.AccessToken.find(accessTokenValue);
|
const accessToken = await tenant.provider.AccessToken.find(accessTokenValue);
|
||||||
|
|
||||||
assertThat(accessToken, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
assertThat(accessToken, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||||
|
|
||||||
|
@ -28,10 +68,22 @@ export default function koaOidcAuth<StateT, ContextT extends IRouterParamContext
|
||||||
assertThat(accountId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
assertThat(accountId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||||
assertThat(scopes.has('openid'), new RequestError({ code: 'auth.forbidden', status: 403 }));
|
assertThat(scopes.has('openid'), new RequestError({ code: 'auth.forbidden', status: 403 }));
|
||||||
|
|
||||||
|
const verificationRecordId = request.headers[verificationRecordIdHeader];
|
||||||
|
const identityVerified =
|
||||||
|
typeof verificationRecordId === 'string'
|
||||||
|
? await getVerificationRecordResultById({
|
||||||
|
id: verificationRecordId,
|
||||||
|
queries: tenant.queries,
|
||||||
|
libraries: tenant.libraries,
|
||||||
|
userId: accountId,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
|
||||||
ctx.auth = {
|
ctx.auth = {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
id: accountId,
|
id: accountId,
|
||||||
scopes,
|
scopes,
|
||||||
|
identityVerified,
|
||||||
};
|
};
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|
|
@ -4,6 +4,8 @@ type Auth = {
|
||||||
type: 'user' | 'app';
|
type: 'user' | 'app';
|
||||||
id: string;
|
id: string;
|
||||||
scopes: Set<string>;
|
scopes: Set<string>;
|
||||||
|
/** If the request is verified by a verification record, this will be set to `true`. */
|
||||||
|
identityVerified?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamContext> =
|
export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamContext> =
|
||||||
|
|
|
@ -104,7 +104,7 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
const anonymousRouter: AnonymousRouter = new Router();
|
||||||
|
|
||||||
const userRouter: UserRouter = new Router();
|
const userRouter: UserRouter = new Router();
|
||||||
userRouter.use(koaOidcAuth(tenant.provider));
|
userRouter.use(koaOidcAuth(tenant));
|
||||||
// TODO(LOG-10147): Rename to koaApiHooks, this middleware is used for both management API and user API
|
// TODO(LOG-10147): Rename to koaApiHooks, this middleware is used for both management API and user API
|
||||||
userRouter.use(koaManagementApiHooks(tenant.libraries.hooks));
|
userRouter.use(koaManagementApiHooks(tenant.libraries.hooks));
|
||||||
profileRoutes(userRouter, tenant);
|
profileRoutes(userRouter, tenant);
|
||||||
|
|
|
@ -118,7 +118,7 @@
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "UpdatePassword",
|
"operationId": "UpdatePassword",
|
||||||
"summary": "Update password",
|
"summary": "Update password",
|
||||||
"description": "Update password for the user, a verification record is required for checking sensitive permissions.",
|
"description": "Update password for the user, a logto-verification-id in header is required for checking sensitive permissions.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
|
@ -126,9 +126,6 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"password": {
|
"password": {
|
||||||
"description": "The new password for the user."
|
"description": "The new password for the user."
|
||||||
},
|
|
||||||
"verificationRecordId": {
|
|
||||||
"description": "The verification record ID for checking sensitive permissions."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -149,7 +146,7 @@
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "UpdatePrimaryEmail",
|
"operationId": "UpdatePrimaryEmail",
|
||||||
"summary": "Update primary email",
|
"summary": "Update primary email",
|
||||||
"description": "Update primary email for the user, a verification record is required for checking sensitive permissions, and a new identifier verification record is required for the new email ownership verification.",
|
"description": "Update primary email for the user, a logto-verification-id in header is required for checking sensitive permissions, and a new identifier verification record is required for the new email ownership verification.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
|
@ -158,9 +155,6 @@
|
||||||
"email": {
|
"email": {
|
||||||
"description": "The new email for the user."
|
"description": "The new email for the user."
|
||||||
},
|
},
|
||||||
"verificationRecordId": {
|
|
||||||
"description": "The verification record ID for checking sensitive permissions."
|
|
||||||
},
|
|
||||||
"newIdentifierVerificationRecordId": {
|
"newIdentifierVerificationRecordId": {
|
||||||
"description": "The identifier verification record ID for the new email ownership verification."
|
"description": "The identifier verification record ID for the new email ownership verification."
|
||||||
}
|
}
|
||||||
|
@ -186,7 +180,7 @@
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "UpdatePrimaryPhone",
|
"operationId": "UpdatePrimaryPhone",
|
||||||
"summary": "Update primary phone",
|
"summary": "Update primary phone",
|
||||||
"description": "Update primary phone for the user, a verification record is required for checking sensitive permissions, and a new identifier verification record is required for the new phone ownership verification.",
|
"description": "Update primary phone for the user, a logto-verification-id in header is required for checking sensitive permissions, and a new identifier verification record is required for the new phone ownership verification.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
|
@ -195,9 +189,6 @@
|
||||||
"phone": {
|
"phone": {
|
||||||
"description": "The new phone for the user."
|
"description": "The new phone for the user."
|
||||||
},
|
},
|
||||||
"verificationRecordId": {
|
|
||||||
"description": "The verification record ID for checking sensitive permissions."
|
|
||||||
},
|
|
||||||
"newIdentifierVerificationRecordId": {
|
"newIdentifierVerificationRecordId": {
|
||||||
"description": "The identifier verification record ID for the new phone ownership verification."
|
"description": "The identifier verification record ID for the new phone ownership verification."
|
||||||
}
|
}
|
||||||
|
@ -223,15 +214,12 @@
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "AddUserIdentities",
|
"operationId": "AddUserIdentities",
|
||||||
"summary": "Add a user identity",
|
"summary": "Add a user identity",
|
||||||
"description": "Add an identity (social identity) to the user, a verification record is required for checking sensitive permissions, and a verification record for the social identity is required.",
|
"description": "Add an identity (social identity) to the user, a logto-verification-id in header is required for checking sensitive permissions, and a verification record for the social identity is required.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"verificationRecordId": {
|
|
||||||
"description": "The verification record ID for checking sensitive permissions."
|
|
||||||
},
|
|
||||||
"newIdentifierVerificationRecordId": {
|
"newIdentifierVerificationRecordId": {
|
||||||
"description": "The identifier verification record ID for the new social identity ownership verification."
|
"description": "The identifier verification record ID for the new social identity ownership verification."
|
||||||
}
|
}
|
||||||
|
@ -251,14 +239,7 @@
|
||||||
"delete": {
|
"delete": {
|
||||||
"operationId": "DeleteIdentity",
|
"operationId": "DeleteIdentity",
|
||||||
"summary": "Delete a user identity",
|
"summary": "Delete a user identity",
|
||||||
"description": "Delete an identity (social identity) from the user, a verification record is required for checking sensitive permissions.",
|
"description": "Delete an identity (social identity) from the user, a logto-verification-id in header is required for checking sensitive permissions.",
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "verificationRecordId",
|
|
||||||
"in": "query",
|
|
||||||
"description": "The verification record ID for checking sensitive permissions."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"204": {
|
||||||
"description": "The identity was deleted successfully."
|
"description": "The identity was deleted successfully."
|
||||||
|
|
|
@ -13,10 +13,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import { EnvSet } from '../../env-set/index.js';
|
import { EnvSet } from '../../env-set/index.js';
|
||||||
import RequestError from '../../errors/RequestError/index.js';
|
import RequestError from '../../errors/RequestError/index.js';
|
||||||
import { encryptUserPassword } from '../../libraries/user.utils.js';
|
import { encryptUserPassword } from '../../libraries/user.utils.js';
|
||||||
import {
|
import { buildVerificationRecordByIdAndType } from '../../libraries/verification.js';
|
||||||
buildVerificationRecordByIdAndType,
|
|
||||||
verifyUserSensitivePermission,
|
|
||||||
} from '../../libraries/verification.js';
|
|
||||||
import assertThat from '../../utils/assert-that.js';
|
import assertThat from '../../utils/assert-that.js';
|
||||||
import { PasswordValidator } from '../experience/classes/libraries/password-validator.js';
|
import { PasswordValidator } from '../experience/classes/libraries/password-validator.js';
|
||||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
@ -144,12 +141,16 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
router.post(
|
router.post(
|
||||||
'/profile/password',
|
'/profile/password',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }),
|
body: z.object({ password: z.string().min(1) }),
|
||||||
status: [204, 400, 401, 422],
|
status: [204, 400, 401, 422],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { id: userId } = ctx.auth;
|
const { id: userId, identityVerified } = ctx.auth;
|
||||||
const { password, verificationRecordId } = ctx.guard.body;
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
|
const { password } = ctx.guard.body;
|
||||||
const { fields } = ctx.accountCenter;
|
const { fields } = ctx.accountCenter;
|
||||||
assertThat(
|
assertThat(
|
||||||
fields.password === AccountCenterControlValue.Edit,
|
fields.password === AccountCenterControlValue.Edit,
|
||||||
|
@ -161,13 +162,6 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
const passwordPolicyChecker = new PasswordValidator(signInExperience.passwordPolicy, user);
|
const passwordPolicyChecker = new PasswordValidator(signInExperience.passwordPolicy, user);
|
||||||
await passwordPolicyChecker.validatePassword(password, user);
|
await passwordPolicyChecker.validatePassword(password, user);
|
||||||
|
|
||||||
await verifyUserSensitivePermission({
|
|
||||||
userId,
|
|
||||||
id: verificationRecordId,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
||||||
const updatedUser = await updateUserById(userId, {
|
const updatedUser = await updateUserById(userId, {
|
||||||
passwordEncrypted,
|
passwordEncrypted,
|
||||||
|
@ -187,14 +181,17 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
email: z.string().regex(emailRegEx),
|
email: z.string().regex(emailRegEx),
|
||||||
verificationRecordId: z.string(),
|
|
||||||
newIdentifierVerificationRecordId: z.string(),
|
newIdentifierVerificationRecordId: z.string(),
|
||||||
}),
|
}),
|
||||||
status: [204, 400, 401],
|
status: [204, 400, 401],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { id: userId, scopes } = ctx.auth;
|
const { id: userId, scopes, identityVerified } = ctx.auth;
|
||||||
const { email, verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body;
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
|
const { email, newIdentifierVerificationRecordId } = ctx.guard.body;
|
||||||
const { fields } = ctx.accountCenter;
|
const { fields } = ctx.accountCenter;
|
||||||
assertThat(
|
assertThat(
|
||||||
fields.email === AccountCenterControlValue.Edit,
|
fields.email === AccountCenterControlValue.Edit,
|
||||||
|
@ -203,13 +200,6 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
|
|
||||||
assertThat(scopes.has(UserScope.Email), 'auth.unauthorized');
|
assertThat(scopes.has(UserScope.Email), 'auth.unauthorized');
|
||||||
|
|
||||||
await verifyUserSensitivePermission({
|
|
||||||
userId,
|
|
||||||
id: verificationRecordId,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check new identifier
|
// Check new identifier
|
||||||
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
||||||
type: VerificationType.EmailVerificationCode,
|
type: VerificationType.EmailVerificationCode,
|
||||||
|
@ -237,14 +227,17 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
phone: z.string().regex(phoneRegEx),
|
phone: z.string().regex(phoneRegEx),
|
||||||
verificationRecordId: z.string(),
|
|
||||||
newIdentifierVerificationRecordId: z.string(),
|
newIdentifierVerificationRecordId: z.string(),
|
||||||
}),
|
}),
|
||||||
status: [204, 400, 401],
|
status: [204, 400, 401],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { id: userId, scopes } = ctx.auth;
|
const { id: userId, scopes, identityVerified } = ctx.auth;
|
||||||
const { phone, verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body;
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
|
const { phone, newIdentifierVerificationRecordId } = ctx.guard.body;
|
||||||
const { fields } = ctx.accountCenter;
|
const { fields } = ctx.accountCenter;
|
||||||
assertThat(
|
assertThat(
|
||||||
fields.phone === AccountCenterControlValue.Edit,
|
fields.phone === AccountCenterControlValue.Edit,
|
||||||
|
@ -253,13 +246,6 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
|
|
||||||
assertThat(scopes.has(UserScope.Phone), 'auth.unauthorized');
|
assertThat(scopes.has(UserScope.Phone), 'auth.unauthorized');
|
||||||
|
|
||||||
await verifyUserSensitivePermission({
|
|
||||||
userId,
|
|
||||||
id: verificationRecordId,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check new identifier
|
// Check new identifier
|
||||||
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
||||||
type: VerificationType.PhoneVerificationCode,
|
type: VerificationType.PhoneVerificationCode,
|
||||||
|
@ -286,14 +272,17 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
'/profile/identities',
|
'/profile/identities',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
verificationRecordId: z.string(),
|
|
||||||
newIdentifierVerificationRecordId: z.string(),
|
newIdentifierVerificationRecordId: z.string(),
|
||||||
}),
|
}),
|
||||||
status: [204, 400, 401],
|
status: [204, 400, 401],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { id: userId, scopes } = ctx.auth;
|
const { id: userId, scopes, identityVerified } = ctx.auth;
|
||||||
const { verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body;
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
|
const { newIdentifierVerificationRecordId } = ctx.guard.body;
|
||||||
const { fields } = ctx.accountCenter;
|
const { fields } = ctx.accountCenter;
|
||||||
assertThat(
|
assertThat(
|
||||||
fields.social === AccountCenterControlValue.Edit,
|
fields.social === AccountCenterControlValue.Edit,
|
||||||
|
@ -302,13 +291,6 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
|
|
||||||
assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized');
|
assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized');
|
||||||
|
|
||||||
await verifyUserSensitivePermission({
|
|
||||||
userId,
|
|
||||||
id: verificationRecordId,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check new identifier
|
// Check new identifier
|
||||||
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
||||||
type: VerificationType.Social,
|
type: VerificationType.Social,
|
||||||
|
@ -350,15 +332,14 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
'/profile/identities/:target',
|
'/profile/identities/:target',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
params: z.object({ target: z.string() }),
|
params: z.object({ target: z.string() }),
|
||||||
query: z.object({
|
|
||||||
// TODO: Move all sensitive permission checks to the header
|
|
||||||
verificationRecordId: z.string(),
|
|
||||||
}),
|
|
||||||
status: [204, 400, 401, 404],
|
status: [204, 400, 401, 404],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { id: userId, scopes } = ctx.auth;
|
const { id: userId, scopes, identityVerified } = ctx.auth;
|
||||||
const { verificationRecordId } = ctx.guard.query;
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
const { target } = ctx.guard.params;
|
const { target } = ctx.guard.params;
|
||||||
const { fields } = ctx.accountCenter;
|
const { fields } = ctx.accountCenter;
|
||||||
assertThat(
|
assertThat(
|
||||||
|
@ -368,13 +349,6 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
|
|
||||||
assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized');
|
assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized');
|
||||||
|
|
||||||
await verifyUserSensitivePermission({
|
|
||||||
userId,
|
|
||||||
id: verificationRecordId,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = await findUserById(userId);
|
const user = await findUserById(userId);
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
import { type UserProfileResponse } from '@logto/schemas';
|
import { type UserProfileResponse } from '@logto/schemas';
|
||||||
import { type KyInstance } from 'ky';
|
import { type KyInstance } from 'ky';
|
||||||
|
|
||||||
|
const verificationRecordIdHeader = 'logto-verification-id';
|
||||||
|
|
||||||
export const updatePassword = async (
|
export const updatePassword = async (
|
||||||
api: KyInstance,
|
api: KyInstance,
|
||||||
verificationRecordId: string,
|
verificationRecordId: string,
|
||||||
password: string
|
password: string
|
||||||
) => api.post('api/profile/password', { json: { password, verificationRecordId } });
|
) =>
|
||||||
|
api.post('api/profile/password', {
|
||||||
|
json: { password },
|
||||||
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
|
});
|
||||||
|
|
||||||
export const updatePrimaryEmail = async (
|
export const updatePrimaryEmail = async (
|
||||||
api: KyInstance,
|
api: KyInstance,
|
||||||
|
@ -14,7 +20,8 @@ export const updatePrimaryEmail = async (
|
||||||
newIdentifierVerificationRecordId: string
|
newIdentifierVerificationRecordId: string
|
||||||
) =>
|
) =>
|
||||||
api.post('api/profile/primary-email', {
|
api.post('api/profile/primary-email', {
|
||||||
json: { email, verificationRecordId, newIdentifierVerificationRecordId },
|
json: { email, newIdentifierVerificationRecordId },
|
||||||
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updatePrimaryPhone = async (
|
export const updatePrimaryPhone = async (
|
||||||
|
@ -24,7 +31,8 @@ export const updatePrimaryPhone = async (
|
||||||
newIdentifierVerificationRecordId: string
|
newIdentifierVerificationRecordId: string
|
||||||
) =>
|
) =>
|
||||||
api.post('api/profile/primary-phone', {
|
api.post('api/profile/primary-phone', {
|
||||||
json: { phone, verificationRecordId, newIdentifierVerificationRecordId },
|
json: { phone, newIdentifierVerificationRecordId },
|
||||||
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateIdentities = async (
|
export const updateIdentities = async (
|
||||||
|
@ -33,7 +41,8 @@ export const updateIdentities = async (
|
||||||
newIdentifierVerificationRecordId: string
|
newIdentifierVerificationRecordId: string
|
||||||
) =>
|
) =>
|
||||||
api.post('api/profile/identities', {
|
api.post('api/profile/identities', {
|
||||||
json: { verificationRecordId, newIdentifierVerificationRecordId },
|
json: { newIdentifierVerificationRecordId },
|
||||||
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteIdentity = async (
|
export const deleteIdentity = async (
|
||||||
|
@ -42,7 +51,7 @@ export const deleteIdentity = async (
|
||||||
verificationRecordId: string
|
verificationRecordId: string
|
||||||
) =>
|
) =>
|
||||||
api.delete(`api/profile/identities/${target}`, {
|
api.delete(`api/profile/identities/${target}`, {
|
||||||
searchParams: { verificationRecordId },
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateUser = async (api: KyInstance, body: Record<string, unknown>) =>
|
export const updateUser = async (api: KyInstance, body: Record<string, unknown>) =>
|
||||||
|
|
|
@ -13,12 +13,14 @@ import {
|
||||||
updatePrimaryPhone,
|
updatePrimaryPhone,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from '#src/api/profile.js';
|
} from '#src/api/profile.js';
|
||||||
|
import { createVerificationRecordByPassword } from '#src/api/verification-record.js';
|
||||||
import { expectRejects } from '#src/helpers/index.js';
|
import { expectRejects } from '#src/helpers/index.js';
|
||||||
import {
|
import {
|
||||||
createDefaultTenantUserWithPassword,
|
createDefaultTenantUserWithPassword,
|
||||||
deleteDefaultTenantUser,
|
deleteDefaultTenantUser,
|
||||||
signInAndGetUserApi,
|
signInAndGetUserApi,
|
||||||
} from '#src/helpers/profile.js';
|
} from '#src/helpers/profile.js';
|
||||||
|
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||||
import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js';
|
import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js';
|
||||||
|
|
||||||
const { describe, it } = devFeatureTest;
|
const { describe, it } = devFeatureTest;
|
||||||
|
@ -30,6 +32,7 @@ const expectedError = {
|
||||||
|
|
||||||
describe('profile, account center fields disabled', () => {
|
describe('profile, account center fields disabled', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
await enableAllPasswordSignInMethods();
|
||||||
await updateAccountCenter({
|
await updateAccountCenter({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
fields: {
|
fields: {
|
||||||
|
@ -79,38 +82,29 @@ describe('profile, account center fields disabled', () => {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expectRejects(updatePassword(api, 'verification-record-id', 'new-password'), {
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
await expectRejects(updatePassword(api, verificationRecordId, 'new-password'), {
|
||||||
code: 'account_center.filed_not_editable',
|
code: 'account_center.filed_not_editable',
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
updatePrimaryEmail(
|
updatePrimaryEmail(api, generateEmail(), verificationRecordId, 'new-verification-record-id'),
|
||||||
api,
|
|
||||||
generateEmail(),
|
|
||||||
'verification-record-id',
|
|
||||||
'new-verification-record-id'
|
|
||||||
),
|
|
||||||
expectedError
|
expectedError
|
||||||
);
|
);
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
updatePrimaryPhone(
|
updatePrimaryPhone(api, generatePhone(), verificationRecordId, 'new-verification-record-id'),
|
||||||
api,
|
|
||||||
generatePhone(),
|
|
||||||
'verification-record-id',
|
|
||||||
'new-verification-record-id'
|
|
||||||
),
|
|
||||||
expectedError
|
expectedError
|
||||||
);
|
);
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
updateIdentities(api, 'verification-record-id', 'new-verification-record-id'),
|
updateIdentities(api, verificationRecordId, 'new-verification-record-id'),
|
||||||
expectedError
|
expectedError
|
||||||
);
|
);
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
deleteIdentity(api, mockSocialConnectorTarget, 'verification-record-id'),
|
deleteIdentity(api, mockSocialConnectorTarget, verificationRecordId),
|
||||||
expectedError
|
expectedError
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -33,14 +33,10 @@ describe('profile (email and phone)', () => {
|
||||||
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
const newEmail = generateEmail();
|
const newEmail = generateEmail();
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
updatePrimaryEmail(
|
updatePrimaryEmail(api, newEmail, verificationRecordId, 'new-verification-record-id'),
|
||||||
api,
|
|
||||||
newEmail,
|
|
||||||
'invalid-verification-record-id',
|
|
||||||
'new-verification-record-id'
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
code: 'auth.unauthorized',
|
code: 'auth.unauthorized',
|
||||||
status: 400,
|
status: 400,
|
||||||
|
@ -144,14 +140,10 @@ describe('profile (email and phone)', () => {
|
||||||
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
const newPhone = generatePhone();
|
const newPhone = generatePhone();
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
updatePrimaryPhone(
|
updatePrimaryPhone(api, newPhone, verificationRecordId, 'new-verification-record-id'),
|
||||||
api,
|
|
||||||
newPhone,
|
|
||||||
'invalid-verification-record-id',
|
|
||||||
'new-verification-record-id'
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
code: 'auth.unauthorized',
|
code: 'auth.unauthorized',
|
||||||
status: 400,
|
status: 400,
|
||||||
|
|
|
@ -254,8 +254,9 @@ describe('profile', () => {
|
||||||
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
const newPassword = '123456';
|
const newPassword = '123456';
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
|
||||||
await expectRejects(updatePassword(api, 'invalid-varification-record-id', newPassword), {
|
await expectRejects(updatePassword(api, verificationRecordId, newPassword), {
|
||||||
code: 'password.rejected',
|
code: 'password.rejected',
|
||||||
status: 422,
|
status: 422,
|
||||||
});
|
});
|
||||||
|
|
|
@ -54,9 +54,10 @@ describe('profile (social)', () => {
|
||||||
it('should fail if scope is missing', async () => {
|
it('should fail if scope is missing', async () => {
|
||||||
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
updateIdentities(api, 'invalid-verification-record-id', 'new-verification-record-id'),
|
updateIdentities(api, verificationRecordId, 'new-verification-record-id'),
|
||||||
{
|
{
|
||||||
code: 'auth.unauthorized',
|
code: 'auth.unauthorized',
|
||||||
status: 400,
|
status: 400,
|
||||||
|
@ -107,7 +108,6 @@ describe('profile (social)', () => {
|
||||||
const api = await signInAndGetUserApi(username, password, {
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
scopes: [UserScope.Profile, UserScope.Identities],
|
scopes: [UserScope.Profile, UserScope.Identities],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
createSocialVerificationRecord(api, 'invalid-connector-id', state, redirectUri),
|
createSocialVerificationRecord(api, 'invalid-connector-id', state, redirectUri),
|
||||||
{
|
{
|
||||||
|
@ -173,14 +173,12 @@ describe('profile (social)', () => {
|
||||||
it('should fail if scope is missing', async () => {
|
it('should fail if scope is missing', async () => {
|
||||||
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const api = await signInAndGetUserApi(username, password);
|
const api = await signInAndGetUserApi(username, password);
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
|
||||||
await expectRejects(
|
await expectRejects(deleteIdentity(api, mockSocialConnectorTarget, verificationRecordId), {
|
||||||
deleteIdentity(api, mockSocialConnectorTarget, 'invalid-verification-record-id'),
|
code: 'auth.unauthorized',
|
||||||
{
|
status: 400,
|
||||||
code: 'auth.unauthorized',
|
});
|
||||||
status: 400,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await deleteDefaultTenantUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue