0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): update password in profile (#6571)

This commit is contained in:
wangsijie 2024-09-24 14:56:04 +08:00 committed by GitHub
parent 657236a492
commit 1b001f8fe8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 507 additions and 50 deletions

View file

@ -0,0 +1,43 @@
import { expirationTime } from '../queries/verification-records.js';
import {
buildVerificationRecord,
verificationRecordDataGuard,
} from '../routes/experience/classes/verifications/index.js';
import { type VerificationRecord } from '../routes/experience/classes/verifications/verification-record.js';
import type Libraries from '../tenants/Libraries.js';
import type Queries from '../tenants/Queries.js';
import assertThat from '../utils/assert-that.js';
export const buildUserVerificationRecordById = async (
userId: string,
id: string,
queries: Queries,
libraries: Libraries
) => {
const record = await queries.verificationRecords.findUserActiveVerificationRecordById(userId, id);
assertThat(record, 'verification_record.not_found');
const result = verificationRecordDataGuard.safeParse({
...record.data,
id: record.id,
});
assertThat(result.success, 'verification_record.not_found');
return buildVerificationRecord(libraries, queries, result.data);
};
export const saveVerificationRecord = async (
userId: string,
verificationRecord: VerificationRecord,
queries: Queries
) => {
const { id, ...rest } = verificationRecord.toJson();
return queries.verificationRecords.upsertRecord({
id,
userId,
data: rest,
expiresAt: new Date(Date.now() + expirationTime).valueOf(),
});
};

View file

@ -0,0 +1,42 @@
import { type VerificationRecord, VerificationRecords } from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { convertToIdentifiers } from '#src/utils/sql.js';
import { buildFindEntityByIdWithPool } from '../database/find-entity-by-id.js';
import { buildUpdateWhereWithPool } from '../database/update-where.js';
const { table, fields } = convertToIdentifiers(VerificationRecords);
// Default expiration time for verification records is 10 minutes
// TODO: Remove this after we implement "Account Center" configuration
export const expirationTime = 1000 * 60 * 10;
export class VerificationRecordQueries {
public readonly insert = buildInsertIntoWithPool(this.pool)(VerificationRecords, {
returning: true,
});
public upsertRecord = buildInsertIntoWithPool(this.pool)(VerificationRecords, {
onConflict: {
fields: [fields.id],
setExcludedFields: [fields.expiresAt],
},
});
public readonly update = buildUpdateWhereWithPool(this.pool)(VerificationRecords, true);
public readonly find = buildFindEntityByIdWithPool(this.pool)(VerificationRecords);
constructor(public readonly pool: CommonQueryMethods) {}
public findUserActiveVerificationRecordById = async (userId: string, id: string) => {
return this.pool.maybeOne<VerificationRecord>(sql`
select * from ${table}
where ${fields.userId} = ${userId}
and ${fields.id} = ${id}
and ${fields.expiresAt} > now()
`);
};
}

View file

@ -2,7 +2,7 @@ import {
SentinelActionResult, SentinelActionResult,
SentinelActivityTargetType, SentinelActivityTargetType,
SentinelDecision, SentinelDecision,
type InteractionIdentifier, type VerificationIdentifier,
type Sentinel, type Sentinel,
type SentinelActivityAction, type SentinelActivityAction,
} from '@logto/schemas'; } from '@logto/schemas';
@ -33,7 +33,7 @@ export async function withSentinel<T>(
}: { }: {
sentinel: Sentinel; sentinel: Sentinel;
action: SentinelActivityAction; action: SentinelActivityAction;
identifier: InteractionIdentifier; identifier: VerificationIdentifier;
payload: Record<string, unknown>; payload: Record<string, unknown>;
}, },
verificationPromise: Promise<T> verificationPromise: Promise<T>

View file

@ -1,9 +1,11 @@
import { import {
SignInIdentifier, SignInIdentifier,
type VerificationIdentifier,
VerificationType, VerificationType,
type InteractionIdentifier, type InteractionIdentifier,
type User, type User,
type VerificationCodeSignInIdentifier, type VerificationCodeSignInIdentifier,
AdditionalIdentifier,
} from '@logto/schemas'; } from '@logto/schemas';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
@ -12,7 +14,7 @@ import type { InteractionProfile } from '../types.js';
export const findUserByIdentifier = async ( export const findUserByIdentifier = async (
userQuery: Queries['users'], userQuery: Queries['users'],
{ type, value }: InteractionIdentifier { type, value }: VerificationIdentifier
) => { ) => {
switch (type) { switch (type) {
case SignInIdentifier.Username: { case SignInIdentifier.Username: {
@ -24,6 +26,9 @@ export const findUserByIdentifier = async (
case SignInIdentifier.Phone: { case SignInIdentifier.Phone: {
return userQuery.findUserByPhone(value); return userQuery.findUserByPhone(value);
} }
case AdditionalIdentifier.UserId: {
return userQuery.findUserById(value);
}
} }
}; };

View file

@ -1,8 +1,8 @@
import { import {
type VerificationIdentifier,
VerificationType, VerificationType,
interactionIdentifierGuard,
type InteractionIdentifier,
type User, type User,
verificationIdentifierGuard,
} from '@logto/schemas'; } from '@logto/schemas';
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js'; import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
@ -20,14 +20,14 @@ import { type IdentifierVerificationRecord } from './verification-record.js';
export type PasswordVerificationRecordData = { export type PasswordVerificationRecordData = {
id: string; id: string;
type: VerificationType.Password; type: VerificationType.Password;
identifier: InteractionIdentifier; identifier: VerificationIdentifier;
verified: boolean; verified: boolean;
}; };
export const passwordVerificationRecordDataGuard = z.object({ export const passwordVerificationRecordDataGuard = z.object({
id: z.string(), id: z.string(),
type: z.literal(VerificationType.Password), type: z.literal(VerificationType.Password),
identifier: interactionIdentifierGuard, identifier: verificationIdentifierGuard,
verified: z.boolean(), verified: z.boolean(),
}) satisfies ToZodObject<PasswordVerificationRecordData>; }) satisfies ToZodObject<PasswordVerificationRecordData>;
@ -35,7 +35,7 @@ export class PasswordVerification
implements IdentifierVerificationRecord<VerificationType.Password> implements IdentifierVerificationRecord<VerificationType.Password>
{ {
/** Factory method to create a new `PasswordVerification` record using an identifier */ /** Factory method to create a new `PasswordVerification` record using an identifier */
static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) { static create(libraries: Libraries, queries: Queries, identifier: VerificationIdentifier) {
return new PasswordVerification(libraries, queries, { return new PasswordVerification(libraries, queries, {
id: generateStandardId(), id: generateStandardId(),
type: VerificationType.Password, type: VerificationType.Password,
@ -45,7 +45,7 @@ export class PasswordVerification
} }
readonly type = VerificationType.Password; readonly type = VerificationType.Password;
readonly identifier: InteractionIdentifier; readonly identifier: VerificationIdentifier;
readonly id: string; readonly id: string;
private verified: boolean; private verified: boolean;

View file

@ -43,8 +43,9 @@ import statusRoutes from './status.js';
import subjectTokenRoutes from './subject-token.js'; import subjectTokenRoutes from './subject-token.js';
import swaggerRoutes from './swagger/index.js'; import swaggerRoutes from './swagger/index.js';
import systemRoutes from './system.js'; import systemRoutes from './system.js';
import type { AnonymousRouter, ManagementApiRouter, ProfileRouter } from './types.js'; import type { AnonymousRouter, ManagementApiRouter, UserRouter } from './types.js';
import userAssetsRoutes from './user-assets.js'; import userAssetsRoutes from './user-assets.js';
import verificationRoutes from './verification/index.js';
import verificationCodeRoutes from './verification-code.js'; import verificationCodeRoutes from './verification-code.js';
import wellKnownRoutes from './well-known/index.js'; import wellKnownRoutes from './well-known/index.js';
import wellKnownOpenApiRoutes from './well-known/well-known.openapi.js'; import wellKnownOpenApiRoutes from './well-known/well-known.openapi.js';
@ -97,30 +98,31 @@ const createRouters = (tenant: TenantContext) => {
const anonymousRouter: AnonymousRouter = new Router(); const anonymousRouter: AnonymousRouter = new Router();
wellKnownRoutes(anonymousRouter, tenant); const userRouter: UserRouter = new Router();
wellKnownOpenApiRoutes(anonymousRouter, { profileRoutes(userRouter, tenant);
experienceRouters: [experienceRouter, interactionRouter], verificationRoutes(userRouter, tenant);
managementRouters: [managementRouter, anonymousRouter],
});
wellKnownRoutes(anonymousRouter, tenant);
statusRoutes(anonymousRouter, tenant); statusRoutes(anonymousRouter, tenant);
authnRoutes(anonymousRouter, tenant); authnRoutes(anonymousRouter, tenant);
if (EnvSet.values.isDevFeaturesEnabled) { wellKnownOpenApiRoutes(anonymousRouter, {
const profileRouter: ProfileRouter = new Router(); experienceRouters: [experienceRouter, interactionRouter],
profileRoutes(profileRouter, tenant); managementRouters: [managementRouter, anonymousRouter],
} userRouters: [userRouter],
});
// The swagger.json should contain all API routers. // The swagger.json should contain all API routers.
swaggerRoutes(anonymousRouter, [ swaggerRoutes(anonymousRouter, [
managementRouter, managementRouter,
anonymousRouter, anonymousRouter,
experienceRouter, experienceRouter,
userRouter,
// TODO: interactionRouter should be removed from swagger.json // TODO: interactionRouter should be removed from swagger.json
interactionRouter, interactionRouter,
]); ]);
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter]; return [experienceRouter, interactionRouter, managementRouter, anonymousRouter, userRouter];
}; };
export default function initApis(tenant: TenantContext): Koa { export default function initApis(tenant: TenantContext): Koa {

View file

@ -0,0 +1,44 @@
{
"tags": [
{
"name": "Profile",
"description": "Profile routes provide functionality for managing user profiles for the end user to interact directly with access tokens."
},
{
"name": "Dev feature"
}
],
"paths": {
"/api/profile/password": {
"post": {
"operationId": "UpdatePassword",
"summary": "Update password",
"description": "Update password for the user, a verification record is required.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"password": {
"description": "The new password for the user."
},
"verificationRecordId": {
"description": "The verification record ID."
}
}
}
}
}
},
"responses": {
"204": {
"description": "The password was updated successfully."
},
"400": {
"description": "The verification record is invalid."
}
}
}
}
}
}

View file

@ -2,32 +2,51 @@ import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import { EnvSet } from '../../env-set/index.js';
import { encryptUserPassword } from '../../libraries/user.utils.js';
import { buildUserVerificationRecordById } from '../../libraries/verification.js';
import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js'; import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js';
import type { ProfileRouter, RouterInitArgs } from '../types.js'; import assertThat from '../../utils/assert-that.js';
import type { UserRouter, RouterInitArgs } from '../types.js';
/** export default function profileRoutes<T extends UserRouter>(
* Authn stands for authentication. ...[router, { provider, queries, libraries }]: RouterInitArgs<T>
* This router will have a route `/authn` to authenticate tokens with a general manner.
*/
export default function profileRoutes<T extends ProfileRouter>(
...[router, { provider }]: RouterInitArgs<T>
) { ) {
const {
users: { updateUserById },
} = queries;
router.use(koaOidcAuth(provider)); router.use(koaOidcAuth(provider));
// TODO: test route only, will implement a better one later if (!EnvSet.values.isDevFeaturesEnabled) {
router.get( return;
'/profile', }
router.post(
'/profile/password',
koaGuard({ koaGuard({
response: z.object({ body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }),
sub: z.string(), status: [204, 400],
}),
status: [200],
}), }),
async (ctx, next) => { async (ctx, next) => {
ctx.body = { const { id: userId } = ctx.auth;
sub: ctx.auth.id, const { password, verificationRecordId } = ctx.guard.body;
};
ctx.status = 200; // TODO(LOG-9947): apply password policy
// TODO(LOG-10005): trigger user updated webhook
const verificationRecord = await buildUserVerificationRecordById(
userId,
verificationRecordId,
queries,
libraries
);
assertThat(verificationRecord.isVerified, 'verification_record.not_found');
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
ctx.status = 204;
return next(); return next();
} }

View file

@ -57,3 +57,8 @@ curl --location \\
\`\`\` \`\`\`
Replace \`[tenant-id]\` with your Logto tenant ID and \`eyJhbG...2g\` with the access token you fetched earlier.`; Replace \`[tenant-id]\` with your Logto tenant ID and \`eyJhbG...2g\` with the access token you fetched earlier.`;
export const userApiAuthDescription = `Logto User API is a set of REST APIs that gives the end user the ability to manage their own profile and perform verifications.
To use this API, you need to have an openid access token with empty audience and required scopes.
`;

View file

@ -13,7 +13,7 @@ import assertThat from '#src/utils/assert-that.js';
import { getConsoleLogFromContext } from '#src/utils/console.js'; import { getConsoleLogFromContext } from '#src/utils/console.js';
import { translationSchemas } from '#src/utils/zod.js'; import { translationSchemas } from '#src/utils/zod.js';
import { managementApiAuthDescription } from '../consts.js'; import { managementApiAuthDescription, userApiAuthDescription } from '../consts.js';
import { import {
type FindSupplementFilesOptions, type FindSupplementFilesOptions,
@ -152,6 +152,60 @@ export const buildExperienceApiBaseDocument = (
tags: [...tags].map((tag) => ({ name: tag })), tags: [...tags].map((tag) => ({ name: tag })),
}); });
const userApiIdentifiableEntityNames = Object.freeze(['profile', 'verification']);
export const buildUserApiBaseDocument = (
pathMap: Map<string, OpenAPIV3.PathItemObject>,
tags: Set<string>,
origin: string
): OpenAPIV3.Document => ({
openapi: '3.0.1',
servers: [
{
url: EnvSet.values.isCloud ? 'https://[tenant_id].logto.app/' : origin,
description: 'Logto endpoint address.',
},
],
info: {
title: 'Logto user API references',
description:
'API references for Logto user interaction.' +
condString(
EnvSet.values.isCloud &&
'\n\nNote: The documentation is for Logto Cloud. If you are using Logto OSS, please refer to the response of `/api/swagger.json` endpoint on your Logto instance.'
),
version: 'Cloud',
},
paths: Object.fromEntries(pathMap),
security: [{ cookieAuth: ['all'] }],
components: {
schemas: translationSchemas,
securitySchemes: {
OAuth2: {
type: 'oauth2',
description: userApiAuthDescription,
flows: {
clientCredentials: {
tokenUrl: '/oidc/token',
scopes: {
openid: 'OpenID scope',
profile: 'Profile scope',
},
},
},
},
},
parameters: userApiIdentifiableEntityNames.reduce(
(previous, entityName) => ({
...previous,
...buildPathIdParameters(entityName),
}),
{}
),
},
tags: [...tags].map((tag) => ({ name: tag })),
});
export const getSupplementDocuments = async ( export const getSupplementDocuments = async (
directory = 'routes', directory = 'routes',
option?: FindSupplementFilesOptions option?: FindSupplementFilesOptions

View file

@ -24,10 +24,7 @@ const methodToVerb = Object.freeze({
type RouteDictionary = Record<`${OpenAPIV3.HttpMethods} ${string}`, string>; type RouteDictionary = Record<`${OpenAPIV3.HttpMethods} ${string}`, string>;
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({ const devFeatureCustomRoutes: RouteDictionary = Object.freeze({});
// Subject tokens
'post /subject-tokens': 'CreateSubjectToken',
});
export const customRoutes: Readonly<RouteDictionary> = Object.freeze({ export const customRoutes: Readonly<RouteDictionary> = Object.freeze({
// Authn // Authn
@ -127,6 +124,8 @@ const exceptionPrefixes = Object.freeze([
'/interaction', '/interaction',
'/experience', '/experience',
'/sign-in-exp/default/check-password', '/sign-in-exp/default/check-password',
'/profile',
'/verifications',
]); ]);
const isPathParameter = (segment?: string) => const isPathParameter = (segment?: string) =>

View file

@ -17,7 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext &
export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>; export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;
export type ProfileRouter = Router<unknown, WithAuthContext & WithLogContext & WithI18nContext>; export type UserRouter = Router<unknown, ManagementApiRouterContext>;
type RouterInit<T> = (router: T, tenant: TenantContext) => void; type RouterInit<T> = (router: T, tenant: TenantContext) => void;
export type RouterInitArgs<T> = Parameters<RouterInit<T>>; export type RouterInitArgs<T> = Parameters<RouterInit<T>>;

View file

@ -0,0 +1,41 @@
{
"tags": [
{
"name": "Verifications",
"description": "Endpoints for creating and validating verification records, which can be used in Profile routes."
},
{
"name": "Dev feature"
}
],
"paths": {
"/api/verifications/password": {
"post": {
"operationId": "CreateVerificationByPassword",
"summary": "Create a record by password",
"description": "Create a verification record by verifying the password.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"password": {
"description": "The password of the user."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The verification record was created successfully."
},
"422": {
"description": "The password is invalid."
}
}
}
}
}
}

View file

@ -0,0 +1,60 @@
import { AdditionalIdentifier, SentinelActivityAction } from '@logto/schemas';
import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import { EnvSet } from '../../env-set/index.js';
import { saveVerificationRecord } from '../../libraries/verification.js';
import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js';
import { withSentinel } from '../experience/classes/libraries/sentinel-guard.js';
import { PasswordVerification } from '../experience/classes/verifications/password-verification.js';
import type { UserRouter, RouterInitArgs } from '../types.js';
export default function verificationRoutes<T extends UserRouter>(
...[router, { provider, queries, libraries, sentinel }]: RouterInitArgs<T>
) {
router.use(koaOidcAuth(provider));
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
}
router.post(
'/verifications/password',
koaGuard({
body: z.object({ password: z.string().min(1) }),
response: z.object({ verificationRecordId: z.string() }),
status: [201, 422],
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
const { password } = ctx.guard.body;
const passwordVerification = PasswordVerification.create(libraries, queries, {
type: AdditionalIdentifier.UserId,
value: userId,
});
await withSentinel(
{
sentinel,
action: SentinelActivityAction.Password,
identifier: {
type: AdditionalIdentifier.UserId,
value: userId,
},
payload: {
verificationId: passwordVerification.id,
},
},
passwordVerification.verify(password)
);
await saveVerificationRecord(userId, passwordVerification, queries);
ctx.body = { verificationRecordId: passwordVerification.id };
ctx.status = 201;
return next();
}
);
}

View file

@ -68,6 +68,17 @@
} }
} }
} }
},
"/api/.well-known/user.openapi.json": {
"get": {
"summary": "Get User API swagger JSON",
"description": "The endpoint for the User API JSON document. The JSON conforms to the [OpenAPI v3.0.1](https://spec.openapis.org/oas/v3.0.1) (a.k.a. Swagger) specification.",
"responses": {
"200": {
"description": "The JSON document."
}
}
}
} }
} }
} }

View file

@ -3,6 +3,7 @@ import {
buildManagementApiBaseDocument, buildManagementApiBaseDocument,
getSupplementDocuments, getSupplementDocuments,
assembleSwaggerDocument, assembleSwaggerDocument,
buildUserApiBaseDocument,
} from '#src/routes/swagger/utils/documents.js'; } from '#src/routes/swagger/utils/documents.js';
import { import {
buildRouterObjects, buildRouterObjects,
@ -14,11 +15,12 @@ import { type AnonymousRouter } from '#src/routes/types.js';
type OpenApiRouters<R> = { type OpenApiRouters<R> = {
managementRouters: R[]; managementRouters: R[];
experienceRouters: R[]; experienceRouters: R[];
userRouters: R[];
}; };
export default function openapiRoutes<T extends AnonymousRouter, R extends UnknownRouter>( export default function openapiRoutes<T extends AnonymousRouter, R extends UnknownRouter>(
router: T, router: T,
{ managementRouters, experienceRouters }: OpenApiRouters<R> { managementRouters, experienceRouters, userRouters }: OpenApiRouters<R>
) { ) {
router.get('/.well-known/management.openapi.json', async (ctx, next) => { router.get('/.well-known/management.openapi.json', async (ctx, next) => {
const managementApiRoutes = buildRouterObjects(managementRouters, { const managementApiRoutes = buildRouterObjects(managementRouters, {
@ -29,7 +31,7 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
// Find supplemental documents // Find supplemental documents
const supplementDocuments = await getSupplementDocuments('routes', { const supplementDocuments = await getSupplementDocuments('routes', {
excludeDirectories: ['experience', 'interaction'], excludeDirectories: ['experience', 'interaction', 'profile', 'verification'],
}); });
const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin); const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin);
@ -62,4 +64,24 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
return next(); return next();
}); });
router.get('/.well-known/user.openapi.json', async (ctx, next) => {
const userApiRoutes = buildRouterObjects(userRouters);
const { pathMap, tags } = groupRoutesByPath(userApiRoutes);
// Find supplemental documents
const supplementDocuments = await getSupplementDocuments('routes', {
includeDirectories: ['profile', 'verification'],
});
const baseDocument = buildUserApiBaseDocument(pathMap, tags, ctx.request.origin);
const data = assembleSwaggerDocument(supplementDocuments, baseDocument, ctx);
ctx.body = {
...data,
tags: data.tags?.slice().sort((tagA, tagB) => tagA.name.localeCompare(tagB.name)),
};
return next();
});
} }

View file

@ -30,6 +30,7 @@ import { createUsersRolesQueries } from '#src/queries/users-roles.js';
import { createVerificationStatusQueries } from '#src/queries/verification-status.js'; import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js'; import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js';
import { VerificationRecordQueries } from '../queries/verification-records.js';
export default class Queries { export default class Queries {
applications = createApplicationQueries(this.pool); applications = createApplicationQueries(this.pool);
@ -59,6 +60,7 @@ export default class Queries {
userSsoIdentities = new UserSsoIdentityQueries(this.pool); userSsoIdentities = new UserSsoIdentityQueries(this.pool);
subjectTokens = createSubjectTokenQueries(this.pool); subjectTokens = createSubjectTokenQueries(this.pool);
personalAccessTokens = new PersonalAccessTokensQueries(this.pool); personalAccessTokens = new PersonalAccessTokensQueries(this.pool);
verificationRecords = new VerificationRecordQueries(this.pool);
tenants = createTenantQueries(this.pool); tenants = createTenantQueries(this.pool);
constructor( constructor(

View file

@ -0,0 +1,7 @@
import { type KyInstance } from 'ky';
export const updatePassword = async (
api: KyInstance,
verificationRecordId: string,
password: string
) => api.post('profile/password', { json: { password, verificationRecordId } });

View file

@ -0,0 +1,13 @@
import { type KyInstance } from 'ky';
export const createVerificationRecordByPassword = async (api: KyInstance, password: string) => {
const { verificationRecordId } = await api
.post('verifications/password', {
json: {
password,
},
})
.json<{ verificationRecordId: string }>();
return verificationRecordId;
};

View file

@ -29,7 +29,7 @@ import {
export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId); export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId);
export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me'); export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me');
const createUserWithRoles = async (roleNames: string[]) => { export const createUserWithPassword = async () => {
const username = generateUsername(); const username = generateUsername();
const password = generatePassword(); const password = generatePassword();
const user = await api const user = await api
@ -38,6 +38,12 @@ const createUserWithRoles = async (roleNames: string[]) => {
}) })
.json<User>(); .json<User>();
return { user, username, password };
};
const createUserWithRoles = async (roleNames: string[]) => {
const { user, username, password } = await createUserWithPassword();
// Should have roles for default tenant Management API and admin tenant Me API // Should have roles for default tenant Management API and admin tenant Me API
const roles = await api.get('roles').json<Role[]>(); const roles = await api.get('roles').json<Role[]>();
await Promise.all( await Promise.all(
@ -108,6 +114,16 @@ export const initClientAndSignIn = async (
return client; return client;
}; };
export const signInAndGetProfileApi = async (username: string, password: string) => {
const client = await initClientAndSignIn(username, password);
const accessToken = await client.getAccessToken();
return api.extend({
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
};
export const createUserWithAllRolesAndSignInToClient = async () => { export const createUserWithAllRolesAndSignInToClient = async () => {
const [{ id }, { username, password }] = await createUserWithAllRoles(); const [{ id }, { username, password }] = await createUserWithAllRoles();
const client = await initClientAndSignIn(username, password, { const client = await initClientAndSignIn(username, password, {

View file

@ -23,6 +23,7 @@ const identifiersTypeToUserProfile = Object.freeze({
username: 'username', username: 'username',
email: 'primaryEmail', email: 'primaryEmail',
phone: 'primaryPhone', phone: 'primaryPhone',
userId: '',
}); });
describe('sign-in with password verification happy path', () => { describe('sign-in with password verification happy path', () => {
@ -31,7 +32,7 @@ describe('sign-in with password verification happy path', () => {
await Promise.all([setEmailConnector(), setSmsConnector()]); await Promise.all([setEmailConnector(), setSmsConnector()]);
}); });
it.each(Object.values(SignInIdentifier))( it.each([SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone])(
'should sign-in with password using %p', 'should sign-in with password using %p',
async (identifier) => { async (identifier) => {
const { userProfile, user } = await generateNewUser({ const { userProfile, user } = await generateNewUser({

View file

@ -8,10 +8,11 @@ const identifiersTypeToUserProfile = Object.freeze({
username: 'username', username: 'username',
email: 'primaryEmail', email: 'primaryEmail',
phone: 'primaryPhone', phone: 'primaryPhone',
userId: '',
}); });
describe('password verifications', () => { describe('password verifications', () => {
it.each(Object.values(SignInIdentifier))( it.each([SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone])(
'should verify with password successfully using %p', 'should verify with password successfully using %p',
async (identifier) => { async (identifier) => {
const { userProfile, user } = await generateNewUser({ const { userProfile, user } = await generateNewUser({

View file

@ -0,0 +1,48 @@
import { updatePassword } from '#src/api/profile.js';
import { createVerificationRecordByPassword } from '#src/api/verification-record.js';
import {
createUserWithPassword,
deleteUser,
initClientAndSignIn,
signInAndGetProfileApi,
} from '#src/helpers/admin-tenant.js';
import { expectRejects } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { devFeatureTest, generatePassword } from '#src/utils.js';
const { describe, it } = devFeatureTest;
describe('profile', () => {
beforeAll(async () => {
await enableAllPasswordSignInMethods();
});
describe('POST /profile/password', () => {
it('should fail if verification record is invalid', async () => {
const { user, username, password } = await createUserWithPassword();
const api = await signInAndGetProfileApi(username, password);
const newPassword = generatePassword();
await expectRejects(updatePassword(api, 'invalid-varification-record-id', newPassword), {
code: 'verification_record.not_found',
status: 400,
});
await deleteUser(user.id);
});
it('should be able to update password', async () => {
const { user, username, password } = await createUserWithPassword();
const api = await signInAndGetProfileApi(username, password);
const verificationRecordId = await createVerificationRecordByPassword(api, password);
const newPassword = generatePassword();
await updatePassword(api, verificationRecordId, newPassword);
// Sign in with new password
await initClientAndSignIn(username, newPassword);
await deleteUser(user.id);
});
});
});

View file

@ -7,7 +7,7 @@ import { adminTenantApi } from '#src/api/api.js';
const { default: OpenApiSchemaValidator } = Validator; const { default: OpenApiSchemaValidator } = Validator;
describe('.well-known openapi.json endpoints', () => { describe('.well-known openapi.json endpoints', () => {
it.each(['management', 'experience'])('should return %s.openapi.json', async (type) => { it.each(['management', 'experience', 'user'])('should return %s.openapi.json', async (type) => {
const response = await adminTenantApi.get(`.well-known/${type}.openapi.json`); const response = await adminTenantApi.get(`.well-known/${type}.openapi.json`);
expect(response).toHaveProperty('status', 200); expect(response).toHaveProperty('status', 200);

View file

@ -23,6 +23,7 @@ import subscription from './subscription.js';
import swagger from './swagger.js'; import swagger from './swagger.js';
import user from './user.js'; import user from './user.js';
import verification_code from './verification-code.js'; import verification_code from './verification-code.js';
import verification_record from './verification-record.js';
const errors = { const errors = {
request, request,
@ -50,6 +51,7 @@ const errors = {
application, application,
organization, organization,
single_sign_on, single_sign_on,
verification_record,
}; };
export default Object.freeze(errors); export default Object.freeze(errors);

View file

@ -0,0 +1,5 @@
const verification_record = {
not_found: 'Verification record not found.',
};
export default Object.freeze(verification_record);

View file

@ -49,6 +49,10 @@ export enum SignInIdentifier {
export const signInIdentifierGuard = z.nativeEnum(SignInIdentifier); export const signInIdentifierGuard = z.nativeEnum(SignInIdentifier);
export enum AdditionalIdentifier {
UserId = 'userId',
}
export const signUpGuard = z.object({ export const signUpGuard = z.object({
identifiers: z.nativeEnum(SignInIdentifier).array(), identifiers: z.nativeEnum(SignInIdentifier).array(),
password: z.boolean(), password: z.boolean(),

View file

@ -2,6 +2,7 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import { z } from 'zod'; import { z } from 'zod';
import { import {
AdditionalIdentifier,
MfaFactor, MfaFactor,
SignInIdentifier, SignInIdentifier,
jsonObjectGuard, jsonObjectGuard,
@ -28,6 +29,16 @@ export enum InteractionEvent {
ForgotPassword = 'ForgotPassword', ForgotPassword = 'ForgotPassword',
} }
export type VerificationIdentifier = {
type: SignInIdentifier | AdditionalIdentifier;
value: string;
};
export const verificationIdentifierGuard = z.object({
type: z.union([z.nativeEnum(SignInIdentifier), z.nativeEnum(AdditionalIdentifier)]),
value: z.string(),
}) satisfies ToZodObject<VerificationIdentifier>;
// ====== Experience API payload guards and type definitions start ====== // ====== Experience API payload guards and type definitions start ======
/** Identifiers that can be used to uniquely identify a user. */ /** Identifiers that can be used to uniquely identify a user. */