mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): password checking api (#6567)
* feat(core): password checking api * refactor(core): improve API response
This commit is contained in:
parent
b639249159
commit
cc346b4e0a
9 changed files with 321 additions and 12 deletions
34
.changeset/yellow-yaks-bathe.md
Normal file
34
.changeset/yellow-yaks-bathe.md
Normal file
|
@ -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" }
|
||||
]
|
||||
}
|
||||
```
|
|
@ -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<T extends AuthedMeRouter>(
|
|||
const {
|
||||
queries: {
|
||||
users: { findUserById, updateUserById },
|
||||
signInExperiences: { findDefaultSignInExperience },
|
||||
},
|
||||
libraries: {
|
||||
users: { checkIdentifierCollision, verifyUserPassword },
|
||||
|
@ -118,7 +120,6 @@ export default function userRoutes<T extends AuthedMeRouter>(
|
|||
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<T extends AuthedMeRouter>(
|
|||
|
||||
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 });
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<T extends ManagementApiRouter>(
|
|||
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<T extends ManagementApiRouter>(
|
|||
}
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -122,7 +122,12 @@ export const throwByDifference = (builtCustomRoutes: Set<string>) => {
|
|||
};
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -100,5 +100,5 @@ export const createUserWithAllRolesAndSignInToClient = async () => {
|
|||
scopes: [PredefinedScope.All],
|
||||
});
|
||||
|
||||
return { id, client };
|
||||
return { id, client, username, password };
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue