mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): reauth by password
This commit is contained in:
parent
e405ef7bb8
commit
393bf36f23
5 changed files with 123 additions and 12 deletions
|
@ -7,6 +7,8 @@ import RequestError from '@/errors/RequestError';
|
|||
import { findUserById, updateUserById } from '@/queries/user';
|
||||
import { maskUserInfo } from '@/utils/format';
|
||||
|
||||
import { updateLastSignInAt } from './user';
|
||||
|
||||
export const assignInteractionResults = async (
|
||||
ctx: Context,
|
||||
provider: Provider,
|
||||
|
@ -73,6 +75,32 @@ export const checkProtectedAccess = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const reAuthenticateSession = async (ctx: Context, provider: Provider) => {
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
if (!result?.login?.accountId) {
|
||||
throw new RequestError('auth.unauthorized');
|
||||
}
|
||||
|
||||
await updateLastSignInAt(result.login.accountId);
|
||||
|
||||
const ts = dayjs().unix();
|
||||
await provider.interactionResult(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
{
|
||||
...result,
|
||||
login: {
|
||||
...result.login,
|
||||
ts,
|
||||
},
|
||||
},
|
||||
{ mergeWithLastSubmission: true }
|
||||
);
|
||||
|
||||
return { ts };
|
||||
};
|
||||
|
||||
export const saveUserFirstConsentedAppId = async (userId: string, applicationId: string) => {
|
||||
const { applicationId: firstConsentedAppId } = await findUserById(userId);
|
||||
|
||||
|
|
|
@ -6,7 +6,11 @@ import { mockUser } from '@/__mocks__';
|
|||
import RequestError from '@/errors/RequestError';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
import usernamePasswordRoutes, { registerRoute, signInRoute } from './username-password';
|
||||
import usernamePasswordRoutes, {
|
||||
registerRoute,
|
||||
signInRoute,
|
||||
reAuthRoute,
|
||||
} from './username-password';
|
||||
|
||||
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
||||
|
@ -29,7 +33,7 @@ jest.mock('@/queries/user', () => ({
|
|||
|
||||
jest.mock('@/lib/user', () => ({
|
||||
async findUserByUsernameAndPassword(username: string, password: string) {
|
||||
if (username !== 'username' && username !== 'admin') {
|
||||
if (username !== 'foo' && username !== 'admin') {
|
||||
throw new RequestError('session.invalid_credentials');
|
||||
}
|
||||
|
||||
|
@ -103,7 +107,7 @@ describe('sessionRoutes', () => {
|
|||
it('assign result and redirect', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
||||
const response = await sessionRequest.post(signInRoute).send({
|
||||
username: 'username',
|
||||
username: 'foo',
|
||||
password: 'password',
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
@ -129,7 +133,7 @@ describe('sessionRoutes', () => {
|
|||
it('throw if user found but wrong password', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
||||
const response = await sessionRequest.post(signInRoute).send({
|
||||
username: 'username',
|
||||
username: 'foo',
|
||||
password: '_password',
|
||||
});
|
||||
expect(response.statusCode).toEqual(400);
|
||||
|
@ -140,7 +144,7 @@ describe('sessionRoutes', () => {
|
|||
params: { client_id: adminConsoleApplicationId },
|
||||
});
|
||||
const response = await sessionRequest.post(signInRoute).send({
|
||||
username: 'username',
|
||||
username: 'foo',
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
|
@ -167,11 +171,11 @@ describe('sessionRoutes', () => {
|
|||
|
||||
const response = await sessionRequest
|
||||
.post(registerRoute)
|
||||
.send({ username: 'username', password: 'password' });
|
||||
.send({ username: 'foo', password: 'password' });
|
||||
expect(insertUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'user1',
|
||||
username: 'username',
|
||||
username: 'foo',
|
||||
passwordEncrypted: 'password_user1',
|
||||
passwordEncryptionMethod: 'Argon2i',
|
||||
roleNames: [],
|
||||
|
@ -194,7 +198,7 @@ describe('sessionRoutes', () => {
|
|||
|
||||
hasActiveUsers.mockResolvedValueOnce(false);
|
||||
|
||||
await sessionRequest.post(registerRoute).send({ username: 'username', password: 'password' });
|
||||
await sessionRequest.post(registerRoute).send({ username: 'foo', password: 'password' });
|
||||
|
||||
expect(insertUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
@ -208,7 +212,7 @@ describe('sessionRoutes', () => {
|
|||
params: { client_id: adminConsoleApplicationId },
|
||||
});
|
||||
|
||||
await sessionRequest.post(registerRoute).send({ username: 'username', password: 'password' });
|
||||
await sessionRequest.post(registerRoute).send({ username: 'foo', password: 'password' });
|
||||
|
||||
expect(insertUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
@ -232,4 +236,45 @@ describe('sessionRoutes', () => {
|
|||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/re-auth/username-password', () => {
|
||||
it('should update login.ts', async () => {
|
||||
interactionDetails.mockResolvedValue({
|
||||
params: {},
|
||||
result: { login: { accountId: 'foo', ts: 0 } },
|
||||
});
|
||||
const response = await sessionRequest.post(reAuthRoute).send({
|
||||
password: 'password',
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body).toHaveProperty('ts');
|
||||
expect(response.body.ts).toBeGreaterThan(0);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expect.objectContaining({ login: expect.objectContaining({ ts: expect.anything() }) }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if the password is wrong', async () => {
|
||||
interactionDetails.mockResolvedValue({
|
||||
params: {},
|
||||
result: { login: { accountId: 'foo', ts: 0 } },
|
||||
});
|
||||
const response = await sessionRequest.post(reAuthRoute).send({
|
||||
password: '_password',
|
||||
});
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
|
||||
it('should throw if current session is not authenticated before', async () => {
|
||||
interactionDetails.mockResolvedValue({ params: {} });
|
||||
const response = await sessionRequest.post(reAuthRoute).send({
|
||||
password: 'password',
|
||||
});
|
||||
expect(response.statusCode).toEqual(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Provider } from 'oidc-provider';
|
|||
import { object, string } from 'zod';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { assignInteractionResults } from '@/lib/session';
|
||||
import { assignInteractionResults, reAuthenticateSession } from '@/lib/session';
|
||||
import {
|
||||
encryptUserPassword,
|
||||
generateUserId,
|
||||
|
@ -14,7 +14,7 @@ import {
|
|||
insertUser,
|
||||
} from '@/lib/user';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import { hasUser, hasActiveUsers } from '@/queries/user';
|
||||
import { hasUser, hasActiveUsers, findUserById } from '@/queries/user';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
import { AnonymousRouter } from '../types';
|
||||
|
@ -22,6 +22,7 @@ import { getRoutePrefix } from './utils';
|
|||
|
||||
export const registerRoute = getRoutePrefix('register', 'username-password');
|
||||
export const signInRoute = getRoutePrefix('sign-in', 'username-password');
|
||||
export const reAuthRoute = getRoutePrefix('re-auth', 'username-password');
|
||||
|
||||
export default function usernamePasswordRoutes<T extends AnonymousRouter>(
|
||||
router: T,
|
||||
|
@ -109,4 +110,35 @@ export default function usernamePasswordRoutes<T extends AnonymousRouter>(
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
reAuthRoute,
|
||||
koaGuard({
|
||||
body: object({
|
||||
password: string().min(1),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
assertThat(
|
||||
result?.login?.accountId,
|
||||
new RequestError({
|
||||
code: 'auth.unauthorized',
|
||||
status: 401,
|
||||
})
|
||||
);
|
||||
|
||||
const user = await findUserById(result.login.accountId);
|
||||
assertThat(user.username, 'session.invalid_sign_in_method');
|
||||
|
||||
const { password } = ctx.guard.body;
|
||||
const type = 'ReAuthUsernamePassword';
|
||||
ctx.log(type, { username: user.username, userId: user.id });
|
||||
|
||||
await findUserByUsernameAndPassword(user.username, password);
|
||||
ctx.body = await reAuthenticateSession(ctx, provider);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Truthy } from '@silverhand/essentials';
|
||||
|
||||
export const getRoutePrefix = (
|
||||
type: 'sign-in' | 'register',
|
||||
type: 'sign-in' | 'register' | 're-auth',
|
||||
method?: 'passwordless' | 'username-password' | 'social'
|
||||
) => {
|
||||
return ['session', type, method]
|
||||
|
|
|
@ -96,6 +96,11 @@ type SignInSocialLogPayload = SignInSocialBindLogPayload & {
|
|||
redirectTo?: string;
|
||||
};
|
||||
|
||||
type ReAuthUsernamePasswordLogPayload = ArbitraryLogPayload & {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export enum TokenType {
|
||||
AccessToken = 'AccessToken',
|
||||
RefreshToken = 'RefreshToken',
|
||||
|
@ -131,6 +136,7 @@ export type LogPayloads = {
|
|||
SignInSms: SignInSmsLogPayload;
|
||||
SignInSocialBind: SignInSocialBindLogPayload;
|
||||
SignInSocial: SignInSocialLogPayload;
|
||||
ReAuthUsernamePassword: ReAuthUsernamePasswordLogPayload;
|
||||
CodeExchangeToken: ExchangeTokenLogPayload;
|
||||
RefreshTokenExchangeToken: ExchangeTokenLogPayload;
|
||||
RevokeToken: RevokeTokenLogPayload;
|
||||
|
|
Loading…
Reference in a new issue