diff --git a/.changeset/yellow-yaks-bathe.md b/.changeset/yellow-yaks-bathe.md new file mode 100644 index 000000000..02478ae66 --- /dev/null +++ b/.changeset/yellow-yaks-bathe.md @@ -0,0 +1,34 @@ +--- +"@logto/core": minor +"@logto/integration-tests": patch +--- + +add password policy checking api + +Add `POST /api/sign-in-exp/default/check-password` API to check if the password meets the password policy configured in the default sign-in experience. A user ID is required for this API if rejects user info is enabled in the password policy. + +Here's a non-normative example of the request and response: + +```http +POST /api/sign-in-exp/default/check-password +Content-Type: application/json + +{ + "password": "123", + "userId": "some-user-id" +} +``` + +```http +400 Bad Request +Content-Type: application/json + +{ + "result": false, + "issues": [ + { "code": "password_rejected.too_short" }, + { "code": "password_rejected.character_types" }, + { "code": "password_rejected.restricted.sequence" } + ] +} +``` diff --git a/packages/core/src/routes-me/user.ts b/packages/core/src/routes-me/user.ts index 30a3cab42..038e069b2 100644 --- a/packages/core/src/routes-me/user.ts +++ b/packages/core/src/routes-me/user.ts @@ -1,4 +1,4 @@ -import { emailRegEx, usernameRegEx } from '@logto/core-kit'; +import { emailRegEx, PasswordPolicyChecker, usernameRegEx } from '@logto/core-kit'; import { userInfoSelectFields, jsonObjectGuard } from '@logto/schemas'; import { conditional, pick } from '@silverhand/essentials'; import { literal, object, string } from 'zod'; @@ -9,6 +9,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import assertThat from '#src/utils/assert-that.js'; import type { RouterInitArgs } from '../routes/types.js'; +import { checkPasswordPolicyForUser } from '../utils/password.js'; import type { AuthedMeRouter } from './types.js'; @@ -18,6 +19,7 @@ export default function userRoutes( const { queries: { users: { findUserById, updateUserById }, + signInExperiences: { findDefaultSignInExperience }, }, libraries: { users: { checkIdentifierCollision, verifyUserPassword }, @@ -118,7 +120,6 @@ export default function userRoutes( assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); await verifyUserPassword(user, password); - await createVerificationStatus(userId, null); ctx.status = 204; @@ -129,16 +130,25 @@ export default function userRoutes( router.post( '/password', - koaGuard({ body: object({ password: string().min(1) }) }), + koaGuard({ body: object({ password: string().min(1) }), status: [204, 400, 401] }), async (ctx, next) => { const { id: userId } = ctx.auth; const { password } = ctx.guard.body; - const { isSuspended } = await findUserById(userId); + const user = await findUserById(userId); - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - await checkVerificationStatus(userId, null); + const [signInExperience] = await Promise.all([ + findDefaultSignInExperience(), + checkVerificationStatus(userId, null), + ]); + const passwordPolicyChecker = new PasswordPolicyChecker(signInExperience.passwordPolicy); + const issues = await checkPasswordPolicyForUser(passwordPolicyChecker, password, user); + + if (issues.length > 0) { + throw new RequestError('password.rejected', { issues }); + } const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod }); diff --git a/packages/core/src/routes/sign-in-experience/index.openapi.json b/packages/core/src/routes/sign-in-experience/index.openapi.json index 19e9059d8..474535039 100644 --- a/packages/core/src/routes/sign-in-experience/index.openapi.json +++ b/packages/core/src/routes/sign-in-experience/index.openapi.json @@ -132,6 +132,37 @@ } } } + }, + "/api/sign-in-exp/default/check-password": { + "post": { + "operationId": "CheckPasswordWithDefaultSignInExperience", + "summary": "Check if a password meets the password policy", + "description": "Check if a password meets the password policy in the sign-in experience settings.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "password": { + "description": "The password to check." + }, + "userId": { + "description": "The user ID to check the password for. It is required if rejects user info is enabled in the password policy." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The password meets the password policy." + }, + "400": { + "description": "The password does not meet the password policy or no user ID provided." + } + } + } } } } diff --git a/packages/core/src/routes/sign-in-experience/index.ts b/packages/core/src/routes/sign-in-experience/index.ts index 5446dfe0e..de6586701 100644 --- a/packages/core/src/routes/sign-in-experience/index.ts +++ b/packages/core/src/routes/sign-in-experience/index.ts @@ -1,11 +1,15 @@ import { DemoConnector } from '@logto/connector-kit'; +import { PasswordPolicyChecker } from '@logto/core-kit'; import { ConnectorType, SignInExperiences } from '@logto/schemas'; +import { tryThat } from '@silverhand/essentials'; import { literal, object, string, z } from 'zod'; import { validateSignUp, validateSignIn } from '#src/libraries/sign-in-experience/index.js'; import { validateMfa } from '#src/libraries/sign-in-experience/mfa.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import RequestError from '../../errors/RequestError/index.js'; +import { checkPasswordPolicyForUser } from '../../utils/password.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; import customUiAssetsRoutes from './custom-ui-assets/index.js'; @@ -16,6 +20,7 @@ export default function signInExperiencesRoutes( const [router, { queries, libraries, connectors }] = args; const { findDefaultSignInExperience, updateDefaultSignInExperience } = queries.signInExperiences; const { deleteConnectorById } = queries.connectors; + const { findUserById } = queries.users; const { signInExperiences: { validateLanguageInfo }, quota: { guardTenantUsageByKey, reportSubscriptionUpdatesUsage }, @@ -125,5 +130,51 @@ export default function signInExperiencesRoutes( } ); + router.post( + '/sign-in-exp/default/check-password', + koaGuard({ + body: z.object({ password: z.string(), userId: z.string().optional() }), + response: z.object({ result: z.literal(true) }).or( + z.object({ + result: z.literal(false), + issues: z.array( + z.object({ code: z.string(), interpolation: z.record(z.unknown()).optional() }) + ), + }) + ), + status: [200, 400], + }), + async (ctx, next) => { + const { password, userId } = ctx.guard.body; + const [signInExperience, user] = await Promise.all([ + findDefaultSignInExperience(), + userId && findUserById(userId), + ]); + const passwordPolicyChecker = new PasswordPolicyChecker(signInExperience.passwordPolicy); + + const issues = await tryThat( + async () => + user + ? checkPasswordPolicyForUser(passwordPolicyChecker, password, user) + : passwordPolicyChecker.check(password), + (error) => { + if (error instanceof TypeError) { + throw new RequestError('request.invalid_input', { message: error.message }); + } + throw error; + } + ); + + if (issues.length > 0) { + ctx.status = 400; + ctx.body = { result: false, issues }; + return next(); + } + + ctx.body = { result: true }; + return next(); + } + ); + customUiAssetsRoutes(...args); } diff --git a/packages/core/src/routes/swagger/utils/operation-id.ts b/packages/core/src/routes/swagger/utils/operation-id.ts index c2aa4a135..ca682ca2b 100644 --- a/packages/core/src/routes/swagger/utils/operation-id.ts +++ b/packages/core/src/routes/swagger/utils/operation-id.ts @@ -122,7 +122,12 @@ export const throwByDifference = (builtCustomRoutes: Set) => { }; /** Path segments that are treated as namespace prefixes. */ -const namespacePrefixes = Object.freeze(['jit', '.well-known', 'experience']); +const namespacePrefixes = Object.freeze(['jit', '.well-known']); +const exceptionPrefixes = Object.freeze([ + '/interaction', + '/experience', + '/sign-in-exp/default/check-password', +]); const isPathParameter = (segment?: string) => Boolean(segment && (segment.startsWith(':') || segment.startsWith('{'))); @@ -154,7 +159,7 @@ const throwIfNeeded = (method: OpenAPIV3.HttpMethods, path: string) => { * @see {@link methodToVerb} for the mapping of HTTP methods to verbs. * @see {@link namespacePrefixes} for the list of namespace prefixes. */ -// eslint-disable-next-line complexity + export const buildOperationId = (method: OpenAPIV3.HttpMethods, path: string) => { const customOperationId = customRoutes[`${method} ${path}`]; @@ -164,7 +169,7 @@ export const buildOperationId = (method: OpenAPIV3.HttpMethods, path: string) => // Skip interactions APIs as they are going to replaced by the new APIs soon. // Skip experience APIs, as all the experience APIs' `operationId` will be customized in the custom openapi.json documents. - if (path.startsWith('/interaction') || path.startsWith('/experience')) { + if (exceptionPrefixes.some((prefix) => path.startsWith(prefix))) { return; } diff --git a/packages/core/src/utils/password.ts b/packages/core/src/utils/password.ts index e5eb46201..6934e93b7 100644 --- a/packages/core/src/utils/password.ts +++ b/packages/core/src/utils/password.ts @@ -1,6 +1,8 @@ import crypto from 'node:crypto'; -import { UsersPasswordEncryptionMethod } from '@logto/schemas'; +import { type PasswordPolicyChecker } from '@logto/core-kit'; +import { type User, UsersPasswordEncryptionMethod } from '@logto/schemas'; +import { condObject } from '@silverhand/essentials'; import { argon2i } from 'hash-wasm'; import RequestError from '#src/errors/RequestError/index.js'; @@ -25,3 +27,19 @@ export const encryptPassword = async ( outputType: 'encoded', }); }; + +export const checkPasswordPolicyForUser = async ( + policyChecker: PasswordPolicyChecker, + password: string, + user: User +) => { + return policyChecker.check( + password, + condObject({ + email: user.primaryEmail, + username: user.username, + phoneNumber: user.primaryPhone, + name: user.name, + }) + ); +}; diff --git a/packages/integration-tests/src/helpers/admin-tenant.ts b/packages/integration-tests/src/helpers/admin-tenant.ts index d91840c06..cd7448889 100644 --- a/packages/integration-tests/src/helpers/admin-tenant.ts +++ b/packages/integration-tests/src/helpers/admin-tenant.ts @@ -100,5 +100,5 @@ export const createUserWithAllRolesAndSignInToClient = async () => { scopes: [PredefinedScope.All], }); - return { id, client }; + return { id, client, username, password }; }; diff --git a/packages/integration-tests/src/tests/api/me.test.ts b/packages/integration-tests/src/tests/api/me.test.ts index f4032002c..c412e8830 100644 --- a/packages/integration-tests/src/tests/api/me.test.ts +++ b/packages/integration-tests/src/tests/api/me.test.ts @@ -8,6 +8,7 @@ import { resourceMe, } from '#src/helpers/admin-tenant.js'; import { expectRejects } from '#src/helpers/index.js'; +import { generatePassword } from '#src/utils.js'; describe('me', () => { it('should only be available in admin tenant', async () => { @@ -59,4 +60,44 @@ describe('me', () => { await deleteUser(id); }); + + it('should be able to verify and reset password', async () => { + const { id, client, password } = await createUserWithAllRolesAndSignInToClient(); + const headers = { authorization: `Bearer ${await client.getAccessToken(resourceMe)}` }; + + await expectRejects( + ky.post(logtoConsoleUrl + '/me/password/verify', { + headers, + json: { password: 'wrong-password' }, + }), + { + code: 'session.invalid_credentials', + status: 422, + } + ); + + await expect( + ky.post(logtoConsoleUrl + '/me/password/verify', { + headers, + json: { password }, + }) + ).resolves.toHaveProperty('status', 204); + + // Should reject weak password + await expect( + ky.post(logtoConsoleUrl + '/me/password', { + headers, + json: { password: '1' }, + }) + ).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 400 Bad Request]'); + + await expect( + ky.post(logtoConsoleUrl + '/me/password', { + headers, + json: { password: generatePassword() }, + }) + ).resolves.toHaveProperty('status', 204); + + await deleteUser(id); + }); }); diff --git a/packages/integration-tests/src/tests/api/sign-in-experience.test.ts b/packages/integration-tests/src/tests/api/sign-in-experience.test.ts index 1a4a359d1..8aab8fa2d 100644 --- a/packages/integration-tests/src/tests/api/sign-in-experience.test.ts +++ b/packages/integration-tests/src/tests/api/sign-in-experience.test.ts @@ -1,7 +1,15 @@ import { MfaPolicy, SignInIdentifier } from '@logto/schemas'; +import { HTTPError, type ResponsePromise } from 'ky'; -import { getSignInExperience, updateSignInExperience } from '#src/api/index.js'; +import { + authedAdminApi, + createUser, + deleteUser, + getSignInExperience, + updateSignInExperience, +} from '#src/api/index.js'; import { expectRejects } from '#src/helpers/index.js'; +import { generatePassword } from '#src/utils.js'; describe('admin console sign-in experience', () => { it('should get sign-in experience successfully', async () => { @@ -49,3 +57,114 @@ describe('admin console sign-in experience', () => { }); }); }); + +const expectPasswordIssues = async (promise: ResponsePromise, issueCodes: string[]) => { + try { + await promise; + fail('Should have thrown'); + } catch (error) { + if (!(error instanceof HTTPError)) { + throw error; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: { result: false; issues: Array<{ code: string }> } = await error.response.json(); + expect(body.result).toEqual(false); + expect(body.issues.map((issue: { code: string }) => issue.code)).toEqual(issueCodes); + } +}; + +describe('password policy', () => { + beforeAll(async () => { + await updateSignInExperience({ + passwordPolicy: { + length: { min: 8, max: 64 }, + characterTypes: { min: 3 }, + rejects: { + pwned: true, + repetitionAndSequence: true, + userInfo: true, + }, + }, + }); + }); + + it('should throw if rejects user info is enabled but no user id is provided', async () => { + try { + await authedAdminApi.post('sign-in-exp/default/check-password', { + json: { + password: 'johnny13', + }, + }); + fail('Should have thrown'); + } catch (error) { + if (!(error instanceof HTTPError)) { + throw error; + } + + const body: unknown = await error.response.json(); + + console.log(body); + expect(body).toMatchObject({ + code: 'request.invalid_input', + data: { + message: 'User information data is required to check user information.', + }, + }); + } + }); + + it('should throw 400 for weak passwords', async () => { + const user = await createUser({ name: 'Johnny' }); + await Promise.all([ + expectPasswordIssues( + authedAdminApi.post('sign-in-exp/default/check-password', { + json: { + password: '123', + userId: user.id, + }, + }), + [ + 'password_rejected.too_short', + 'password_rejected.character_types', + 'password_rejected.restricted.sequence', + ] + ), + expectPasswordIssues( + authedAdminApi.post('sign-in-exp/default/check-password', { + json: { + password: 'johnny13', + userId: user.id, + }, + }), + ['password_rejected.character_types', 'password_rejected.restricted.user_info'] + ), + expectPasswordIssues( + authedAdminApi.post('sign-in-exp/default/check-password', { + json: { + password: '123456aA', + userId: user.id, + }, + }), + ['password_rejected.pwned', 'password_rejected.restricted.sequence'] + ), + ]); + + await deleteUser(user.id); + }); + + it('should accept strong password', async () => { + const user = await createUser({ name: 'Johnny' }); + + await expect( + authedAdminApi + .post('sign-in-exp/default/check-password', { + json: { + password: generatePassword(), + userId: user.id, + }, + }) + .json() + ).resolves.toHaveProperty('result', true); + }); +});