0
Fork 0
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:
Gao Sun 2024-09-11 14:55:07 +08:00 committed by GitHub
parent b639249159
commit cc346b4e0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 321 additions and 12 deletions

View 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" }
]
}
```

View file

@ -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 });

View file

@ -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."
}
}
}
}
}
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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,
})
);
};

View file

@ -100,5 +100,5 @@ export const createUserWithAllRolesAndSignInToClient = async () => {
scopes: [PredefinedScope.All],
});
return { id, client };
return { id, client, username, password };
};

View file

@ -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);
});
});

View file

@ -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);
});
});