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:
parent
657236a492
commit
1b001f8fe8
28 changed files with 507 additions and 50 deletions
43
packages/core/src/libraries/verification.ts
Normal file
43
packages/core/src/libraries/verification.ts
Normal 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(),
|
||||
});
|
||||
};
|
42
packages/core/src/queries/verification-records.ts
Normal file
42
packages/core/src/queries/verification-records.ts
Normal 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()
|
||||
`);
|
||||
};
|
||||
}
|
|
@ -2,7 +2,7 @@ import {
|
|||
SentinelActionResult,
|
||||
SentinelActivityTargetType,
|
||||
SentinelDecision,
|
||||
type InteractionIdentifier,
|
||||
type VerificationIdentifier,
|
||||
type Sentinel,
|
||||
type SentinelActivityAction,
|
||||
} from '@logto/schemas';
|
||||
|
@ -33,7 +33,7 @@ export async function withSentinel<T>(
|
|||
}: {
|
||||
sentinel: Sentinel;
|
||||
action: SentinelActivityAction;
|
||||
identifier: InteractionIdentifier;
|
||||
identifier: VerificationIdentifier;
|
||||
payload: Record<string, unknown>;
|
||||
},
|
||||
verificationPromise: Promise<T>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import {
|
||||
SignInIdentifier,
|
||||
type VerificationIdentifier,
|
||||
VerificationType,
|
||||
type InteractionIdentifier,
|
||||
type User,
|
||||
type VerificationCodeSignInIdentifier,
|
||||
AdditionalIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
@ -12,7 +14,7 @@ import type { InteractionProfile } from '../types.js';
|
|||
|
||||
export const findUserByIdentifier = async (
|
||||
userQuery: Queries['users'],
|
||||
{ type, value }: InteractionIdentifier
|
||||
{ type, value }: VerificationIdentifier
|
||||
) => {
|
||||
switch (type) {
|
||||
case SignInIdentifier.Username: {
|
||||
|
@ -24,6 +26,9 @@ export const findUserByIdentifier = async (
|
|||
case SignInIdentifier.Phone: {
|
||||
return userQuery.findUserByPhone(value);
|
||||
}
|
||||
case AdditionalIdentifier.UserId: {
|
||||
return userQuery.findUserById(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
type VerificationIdentifier,
|
||||
VerificationType,
|
||||
interactionIdentifierGuard,
|
||||
type InteractionIdentifier,
|
||||
type User,
|
||||
verificationIdentifierGuard,
|
||||
} from '@logto/schemas';
|
||||
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
|
@ -20,14 +20,14 @@ import { type IdentifierVerificationRecord } from './verification-record.js';
|
|||
export type PasswordVerificationRecordData = {
|
||||
id: string;
|
||||
type: VerificationType.Password;
|
||||
identifier: InteractionIdentifier;
|
||||
identifier: VerificationIdentifier;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
export const passwordVerificationRecordDataGuard = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal(VerificationType.Password),
|
||||
identifier: interactionIdentifierGuard,
|
||||
identifier: verificationIdentifierGuard,
|
||||
verified: z.boolean(),
|
||||
}) satisfies ToZodObject<PasswordVerificationRecordData>;
|
||||
|
||||
|
@ -35,7 +35,7 @@ export class PasswordVerification
|
|||
implements IdentifierVerificationRecord<VerificationType.Password>
|
||||
{
|
||||
/** 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, {
|
||||
id: generateStandardId(),
|
||||
type: VerificationType.Password,
|
||||
|
@ -45,7 +45,7 @@ export class PasswordVerification
|
|||
}
|
||||
|
||||
readonly type = VerificationType.Password;
|
||||
readonly identifier: InteractionIdentifier;
|
||||
readonly identifier: VerificationIdentifier;
|
||||
readonly id: string;
|
||||
private verified: boolean;
|
||||
|
||||
|
|
|
@ -43,8 +43,9 @@ import statusRoutes from './status.js';
|
|||
import subjectTokenRoutes from './subject-token.js';
|
||||
import swaggerRoutes from './swagger/index.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 verificationRoutes from './verification/index.js';
|
||||
import verificationCodeRoutes from './verification-code.js';
|
||||
import wellKnownRoutes from './well-known/index.js';
|
||||
import wellKnownOpenApiRoutes from './well-known/well-known.openapi.js';
|
||||
|
@ -97,30 +98,31 @@ const createRouters = (tenant: TenantContext) => {
|
|||
|
||||
const anonymousRouter: AnonymousRouter = new Router();
|
||||
|
||||
wellKnownRoutes(anonymousRouter, tenant);
|
||||
wellKnownOpenApiRoutes(anonymousRouter, {
|
||||
experienceRouters: [experienceRouter, interactionRouter],
|
||||
managementRouters: [managementRouter, anonymousRouter],
|
||||
});
|
||||
const userRouter: UserRouter = new Router();
|
||||
profileRoutes(userRouter, tenant);
|
||||
verificationRoutes(userRouter, tenant);
|
||||
|
||||
wellKnownRoutes(anonymousRouter, tenant);
|
||||
statusRoutes(anonymousRouter, tenant);
|
||||
authnRoutes(anonymousRouter, tenant);
|
||||
|
||||
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||
const profileRouter: ProfileRouter = new Router();
|
||||
profileRoutes(profileRouter, tenant);
|
||||
}
|
||||
wellKnownOpenApiRoutes(anonymousRouter, {
|
||||
experienceRouters: [experienceRouter, interactionRouter],
|
||||
managementRouters: [managementRouter, anonymousRouter],
|
||||
userRouters: [userRouter],
|
||||
});
|
||||
|
||||
// The swagger.json should contain all API routers.
|
||||
swaggerRoutes(anonymousRouter, [
|
||||
managementRouter,
|
||||
anonymousRouter,
|
||||
experienceRouter,
|
||||
userRouter,
|
||||
// TODO: interactionRouter should be removed from swagger.json
|
||||
interactionRouter,
|
||||
]);
|
||||
|
||||
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter];
|
||||
return [experienceRouter, interactionRouter, managementRouter, anonymousRouter, userRouter];
|
||||
};
|
||||
|
||||
export default function initApis(tenant: TenantContext): Koa {
|
||||
|
|
44
packages/core/src/routes/profile/index.openapi.json
Normal file
44
packages/core/src/routes/profile/index.openapi.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,32 +2,51 @@ import { z } from 'zod';
|
|||
|
||||
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 type { ProfileRouter, RouterInitArgs } from '../types.js';
|
||||
import assertThat from '../../utils/assert-that.js';
|
||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
/**
|
||||
* Authn stands for authentication.
|
||||
* 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>
|
||||
export default function profileRoutes<T extends UserRouter>(
|
||||
...[router, { provider, queries, libraries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
users: { updateUserById },
|
||||
} = queries;
|
||||
|
||||
router.use(koaOidcAuth(provider));
|
||||
|
||||
// TODO: test route only, will implement a better one later
|
||||
router.get(
|
||||
'/profile',
|
||||
if (!EnvSet.values.isDevFeaturesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/profile/password',
|
||||
koaGuard({
|
||||
response: z.object({
|
||||
sub: z.string(),
|
||||
}),
|
||||
status: [200],
|
||||
body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }),
|
||||
status: [204, 400],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
ctx.body = {
|
||||
sub: ctx.auth.id,
|
||||
};
|
||||
ctx.status = 200;
|
||||
const { id: userId } = ctx.auth;
|
||||
const { password, verificationRecordId } = ctx.guard.body;
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
|
|
@ -57,3 +57,8 @@ curl --location \\
|
|||
\`\`\`
|
||||
|
||||
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.
|
||||
`;
|
||||
|
|
|
@ -13,7 +13,7 @@ import assertThat from '#src/utils/assert-that.js';
|
|||
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||
import { translationSchemas } from '#src/utils/zod.js';
|
||||
|
||||
import { managementApiAuthDescription } from '../consts.js';
|
||||
import { managementApiAuthDescription, userApiAuthDescription } from '../consts.js';
|
||||
|
||||
import {
|
||||
type FindSupplementFilesOptions,
|
||||
|
@ -152,6 +152,60 @@ export const buildExperienceApiBaseDocument = (
|
|||
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 (
|
||||
directory = 'routes',
|
||||
option?: FindSupplementFilesOptions
|
||||
|
|
|
@ -24,10 +24,7 @@ const methodToVerb = Object.freeze({
|
|||
|
||||
type RouteDictionary = Record<`${OpenAPIV3.HttpMethods} ${string}`, string>;
|
||||
|
||||
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({
|
||||
// Subject tokens
|
||||
'post /subject-tokens': 'CreateSubjectToken',
|
||||
});
|
||||
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({});
|
||||
|
||||
export const customRoutes: Readonly<RouteDictionary> = Object.freeze({
|
||||
// Authn
|
||||
|
@ -127,6 +124,8 @@ const exceptionPrefixes = Object.freeze([
|
|||
'/interaction',
|
||||
'/experience',
|
||||
'/sign-in-exp/default/check-password',
|
||||
'/profile',
|
||||
'/verifications',
|
||||
]);
|
||||
|
||||
const isPathParameter = (segment?: string) =>
|
||||
|
|
|
@ -17,7 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext &
|
|||
|
||||
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;
|
||||
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;
|
||||
|
|
41
packages/core/src/routes/verification/index.openapi.json
Normal file
41
packages/core/src/routes/verification/index.openapi.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
60
packages/core/src/routes/verification/index.ts
Normal file
60
packages/core/src/routes/verification/index.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
buildManagementApiBaseDocument,
|
||||
getSupplementDocuments,
|
||||
assembleSwaggerDocument,
|
||||
buildUserApiBaseDocument,
|
||||
} from '#src/routes/swagger/utils/documents.js';
|
||||
import {
|
||||
buildRouterObjects,
|
||||
|
@ -14,11 +15,12 @@ import { type AnonymousRouter } from '#src/routes/types.js';
|
|||
type OpenApiRouters<R> = {
|
||||
managementRouters: R[];
|
||||
experienceRouters: R[];
|
||||
userRouters: R[];
|
||||
};
|
||||
|
||||
export default function openapiRoutes<T extends AnonymousRouter, R extends UnknownRouter>(
|
||||
router: T,
|
||||
{ managementRouters, experienceRouters }: OpenApiRouters<R>
|
||||
{ managementRouters, experienceRouters, userRouters }: OpenApiRouters<R>
|
||||
) {
|
||||
router.get('/.well-known/management.openapi.json', async (ctx, next) => {
|
||||
const managementApiRoutes = buildRouterObjects(managementRouters, {
|
||||
|
@ -29,7 +31,7 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
|
|||
|
||||
// Find supplemental documents
|
||||
const supplementDocuments = await getSupplementDocuments('routes', {
|
||||
excludeDirectories: ['experience', 'interaction'],
|
||||
excludeDirectories: ['experience', 'interaction', 'profile', 'verification'],
|
||||
});
|
||||
const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin);
|
||||
|
||||
|
@ -62,4 +64,24 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
|
|||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
|||
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
||||
|
||||
import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js';
|
||||
import { VerificationRecordQueries } from '../queries/verification-records.js';
|
||||
|
||||
export default class Queries {
|
||||
applications = createApplicationQueries(this.pool);
|
||||
|
@ -59,6 +60,7 @@ export default class Queries {
|
|||
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
|
||||
subjectTokens = createSubjectTokenQueries(this.pool);
|
||||
personalAccessTokens = new PersonalAccessTokensQueries(this.pool);
|
||||
verificationRecords = new VerificationRecordQueries(this.pool);
|
||||
tenants = createTenantQueries(this.pool);
|
||||
|
||||
constructor(
|
||||
|
|
7
packages/integration-tests/src/api/profile.ts
Normal file
7
packages/integration-tests/src/api/profile.ts
Normal 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 } });
|
13
packages/integration-tests/src/api/verification-record.ts
Normal file
13
packages/integration-tests/src/api/verification-record.ts
Normal 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;
|
||||
};
|
|
@ -29,7 +29,7 @@ import {
|
|||
export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId);
|
||||
export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me');
|
||||
|
||||
const createUserWithRoles = async (roleNames: string[]) => {
|
||||
export const createUserWithPassword = async () => {
|
||||
const username = generateUsername();
|
||||
const password = generatePassword();
|
||||
const user = await api
|
||||
|
@ -38,6 +38,12 @@ const createUserWithRoles = async (roleNames: string[]) => {
|
|||
})
|
||||
.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
|
||||
const roles = await api.get('roles').json<Role[]>();
|
||||
await Promise.all(
|
||||
|
@ -108,6 +114,16 @@ export const initClientAndSignIn = async (
|
|||
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 () => {
|
||||
const [{ id }, { username, password }] = await createUserWithAllRoles();
|
||||
const client = await initClientAndSignIn(username, password, {
|
||||
|
|
|
@ -23,6 +23,7 @@ const identifiersTypeToUserProfile = Object.freeze({
|
|||
username: 'username',
|
||||
email: 'primaryEmail',
|
||||
phone: 'primaryPhone',
|
||||
userId: '',
|
||||
});
|
||||
|
||||
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()]);
|
||||
});
|
||||
|
||||
it.each(Object.values(SignInIdentifier))(
|
||||
it.each([SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone])(
|
||||
'should sign-in with password using %p',
|
||||
async (identifier) => {
|
||||
const { userProfile, user } = await generateNewUser({
|
||||
|
|
|
@ -8,10 +8,11 @@ const identifiersTypeToUserProfile = Object.freeze({
|
|||
username: 'username',
|
||||
email: 'primaryEmail',
|
||||
phone: 'primaryPhone',
|
||||
userId: '',
|
||||
});
|
||||
|
||||
describe('password verifications', () => {
|
||||
it.each(Object.values(SignInIdentifier))(
|
||||
it.each([SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone])(
|
||||
'should verify with password successfully using %p',
|
||||
async (identifier) => {
|
||||
const { userProfile, user } = await generateNewUser({
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -7,7 +7,7 @@ import { adminTenantApi } from '#src/api/api.js';
|
|||
const { default: OpenApiSchemaValidator } = Validator;
|
||||
|
||||
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`);
|
||||
|
||||
expect(response).toHaveProperty('status', 200);
|
||||
|
|
|
@ -23,6 +23,7 @@ import subscription from './subscription.js';
|
|||
import swagger from './swagger.js';
|
||||
import user from './user.js';
|
||||
import verification_code from './verification-code.js';
|
||||
import verification_record from './verification-record.js';
|
||||
|
||||
const errors = {
|
||||
request,
|
||||
|
@ -50,6 +51,7 @@ const errors = {
|
|||
application,
|
||||
organization,
|
||||
single_sign_on,
|
||||
verification_record,
|
||||
};
|
||||
|
||||
export default Object.freeze(errors);
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
const verification_record = {
|
||||
not_found: 'Verification record not found.',
|
||||
};
|
||||
|
||||
export default Object.freeze(verification_record);
|
|
@ -49,6 +49,10 @@ export enum SignInIdentifier {
|
|||
|
||||
export const signInIdentifierGuard = z.nativeEnum(SignInIdentifier);
|
||||
|
||||
export enum AdditionalIdentifier {
|
||||
UserId = 'userId',
|
||||
}
|
||||
|
||||
export const signUpGuard = z.object({
|
||||
identifiers: z.nativeEnum(SignInIdentifier).array(),
|
||||
password: z.boolean(),
|
||||
|
|
|
@ -2,6 +2,7 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
|||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
AdditionalIdentifier,
|
||||
MfaFactor,
|
||||
SignInIdentifier,
|
||||
jsonObjectGuard,
|
||||
|
@ -28,6 +29,16 @@ export enum InteractionEvent {
|
|||
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 ======
|
||||
|
||||
/** Identifiers that can be used to uniquely identify a user. */
|
||||
|
|
Loading…
Reference in a new issue