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 { findUserById, updateUserById } from '@/queries/user';
|
||||||
import { maskUserInfo } from '@/utils/format';
|
import { maskUserInfo } from '@/utils/format';
|
||||||
|
|
||||||
|
import { updateLastSignInAt } from './user';
|
||||||
|
|
||||||
export const assignInteractionResults = async (
|
export const assignInteractionResults = async (
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
provider: Provider,
|
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) => {
|
export const saveUserFirstConsentedAppId = async (userId: string, applicationId: string) => {
|
||||||
const { applicationId: firstConsentedAppId } = await findUserById(userId);
|
const { applicationId: firstConsentedAppId } = await findUserById(userId);
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,11 @@ import { mockUser } from '@/__mocks__';
|
||||||
import RequestError from '@/errors/RequestError';
|
import RequestError from '@/errors/RequestError';
|
||||||
import { createRequester } from '@/utils/test-utils';
|
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 insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
||||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
||||||
|
@ -29,7 +33,7 @@ jest.mock('@/queries/user', () => ({
|
||||||
|
|
||||||
jest.mock('@/lib/user', () => ({
|
jest.mock('@/lib/user', () => ({
|
||||||
async findUserByUsernameAndPassword(username: string, password: string) {
|
async findUserByUsernameAndPassword(username: string, password: string) {
|
||||||
if (username !== 'username' && username !== 'admin') {
|
if (username !== 'foo' && username !== 'admin') {
|
||||||
throw new RequestError('session.invalid_credentials');
|
throw new RequestError('session.invalid_credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,7 +107,7 @@ describe('sessionRoutes', () => {
|
||||||
it('assign result and redirect', async () => {
|
it('assign result and redirect', async () => {
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
interactionDetails.mockResolvedValueOnce({ params: {} });
|
||||||
const response = await sessionRequest.post(signInRoute).send({
|
const response = await sessionRequest.post(signInRoute).send({
|
||||||
username: 'username',
|
username: 'foo',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toEqual(200);
|
expect(response.statusCode).toEqual(200);
|
||||||
|
@ -129,7 +133,7 @@ describe('sessionRoutes', () => {
|
||||||
it('throw if user found but wrong password', async () => {
|
it('throw if user found but wrong password', async () => {
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
interactionDetails.mockResolvedValueOnce({ params: {} });
|
||||||
const response = await sessionRequest.post(signInRoute).send({
|
const response = await sessionRequest.post(signInRoute).send({
|
||||||
username: 'username',
|
username: 'foo',
|
||||||
password: '_password',
|
password: '_password',
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toEqual(400);
|
expect(response.statusCode).toEqual(400);
|
||||||
|
@ -140,7 +144,7 @@ describe('sessionRoutes', () => {
|
||||||
params: { client_id: adminConsoleApplicationId },
|
params: { client_id: adminConsoleApplicationId },
|
||||||
});
|
});
|
||||||
const response = await sessionRequest.post(signInRoute).send({
|
const response = await sessionRequest.post(signInRoute).send({
|
||||||
username: 'username',
|
username: 'foo',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -167,11 +171,11 @@ describe('sessionRoutes', () => {
|
||||||
|
|
||||||
const response = await sessionRequest
|
const response = await sessionRequest
|
||||||
.post(registerRoute)
|
.post(registerRoute)
|
||||||
.send({ username: 'username', password: 'password' });
|
.send({ username: 'foo', password: 'password' });
|
||||||
expect(insertUser).toHaveBeenCalledWith(
|
expect(insertUser).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'user1',
|
id: 'user1',
|
||||||
username: 'username',
|
username: 'foo',
|
||||||
passwordEncrypted: 'password_user1',
|
passwordEncrypted: 'password_user1',
|
||||||
passwordEncryptionMethod: 'Argon2i',
|
passwordEncryptionMethod: 'Argon2i',
|
||||||
roleNames: [],
|
roleNames: [],
|
||||||
|
@ -194,7 +198,7 @@ describe('sessionRoutes', () => {
|
||||||
|
|
||||||
hasActiveUsers.mockResolvedValueOnce(false);
|
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(insertUser).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
@ -208,7 +212,7 @@ describe('sessionRoutes', () => {
|
||||||
params: { client_id: adminConsoleApplicationId },
|
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(insertUser).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
@ -232,4 +236,45 @@ describe('sessionRoutes', () => {
|
||||||
expect(response.statusCode).toEqual(422);
|
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 { object, string } from 'zod';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import RequestError from '@/errors/RequestError';
|
||||||
import { assignInteractionResults } from '@/lib/session';
|
import { assignInteractionResults, reAuthenticateSession } from '@/lib/session';
|
||||||
import {
|
import {
|
||||||
encryptUserPassword,
|
encryptUserPassword,
|
||||||
generateUserId,
|
generateUserId,
|
||||||
|
@ -14,7 +14,7 @@ import {
|
||||||
insertUser,
|
insertUser,
|
||||||
} from '@/lib/user';
|
} from '@/lib/user';
|
||||||
import koaGuard from '@/middleware/koa-guard';
|
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 assertThat from '@/utils/assert-that';
|
||||||
|
|
||||||
import { AnonymousRouter } from '../types';
|
import { AnonymousRouter } from '../types';
|
||||||
|
@ -22,6 +22,7 @@ import { getRoutePrefix } from './utils';
|
||||||
|
|
||||||
export const registerRoute = getRoutePrefix('register', 'username-password');
|
export const registerRoute = getRoutePrefix('register', 'username-password');
|
||||||
export const signInRoute = getRoutePrefix('sign-in', '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>(
|
export default function usernamePasswordRoutes<T extends AnonymousRouter>(
|
||||||
router: T,
|
router: T,
|
||||||
|
@ -109,4 +110,35 @@ export default function usernamePasswordRoutes<T extends AnonymousRouter>(
|
||||||
return next();
|
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';
|
import { Truthy } from '@silverhand/essentials';
|
||||||
|
|
||||||
export const getRoutePrefix = (
|
export const getRoutePrefix = (
|
||||||
type: 'sign-in' | 'register',
|
type: 'sign-in' | 'register' | 're-auth',
|
||||||
method?: 'passwordless' | 'username-password' | 'social'
|
method?: 'passwordless' | 'username-password' | 'social'
|
||||||
) => {
|
) => {
|
||||||
return ['session', type, method]
|
return ['session', type, method]
|
||||||
|
|
|
@ -96,6 +96,11 @@ type SignInSocialLogPayload = SignInSocialBindLogPayload & {
|
||||||
redirectTo?: string;
|
redirectTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ReAuthUsernamePasswordLogPayload = ArbitraryLogPayload & {
|
||||||
|
userId?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export enum TokenType {
|
export enum TokenType {
|
||||||
AccessToken = 'AccessToken',
|
AccessToken = 'AccessToken',
|
||||||
RefreshToken = 'RefreshToken',
|
RefreshToken = 'RefreshToken',
|
||||||
|
@ -131,6 +136,7 @@ export type LogPayloads = {
|
||||||
SignInSms: SignInSmsLogPayload;
|
SignInSms: SignInSmsLogPayload;
|
||||||
SignInSocialBind: SignInSocialBindLogPayload;
|
SignInSocialBind: SignInSocialBindLogPayload;
|
||||||
SignInSocial: SignInSocialLogPayload;
|
SignInSocial: SignInSocialLogPayload;
|
||||||
|
ReAuthUsernamePassword: ReAuthUsernamePasswordLogPayload;
|
||||||
CodeExchangeToken: ExchangeTokenLogPayload;
|
CodeExchangeToken: ExchangeTokenLogPayload;
|
||||||
RefreshTokenExchangeToken: ExchangeTokenLogPayload;
|
RefreshTokenExchangeToken: ExchangeTokenLogPayload;
|
||||||
RevokeToken: RevokeTokenLogPayload;
|
RevokeToken: RevokeTokenLogPayload;
|
||||||
|
|
Loading…
Reference in a new issue