diff --git a/packages/core/src/lib/session.ts b/packages/core/src/lib/session.ts index 33c60f4d8..14ac54138 100644 --- a/packages/core/src/lib/session.ts +++ b/packages/core/src/lib/session.ts @@ -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); diff --git a/packages/core/src/routes/session/username-password.test.ts b/packages/core/src/routes/session/username-password.test.ts index faf480a5e..6c7dabc6a 100644 --- a/packages/core/src/routes/session/username-password.test.ts +++ b/packages/core/src/routes/session/username-password.test.ts @@ -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 => 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); + }); + }); }); diff --git a/packages/core/src/routes/session/username-password.ts b/packages/core/src/routes/session/username-password.ts index d82e39572..58e0be86d 100644 --- a/packages/core/src/routes/session/username-password.ts +++ b/packages/core/src/routes/session/username-password.ts @@ -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( router: T, @@ -109,4 +110,35 @@ export default function usernamePasswordRoutes( 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(); + } + ); } diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index c8a560391..42dd3e95e 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -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] diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts index c65cc07a7..76d94db62 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log.ts @@ -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;