feat: lock auth session ()

This commit is contained in:
Jason Rasmussen 2025-05-15 18:08:31 -04:00 committed by GitHub
parent ecb66fdb2c
commit c1150fe7e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 765 additions and 123 deletions

View file

@ -9,7 +9,9 @@ import {
LoginResponseDto,
LogoutResponseDto,
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
SessionUnlockDto,
SignUpDto,
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
@ -98,14 +100,21 @@ export class AuthController {
@Delete('pin-code')
@Authenticated()
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
@Post('pin-code/verify')
@Post('session/unlock')
@HttpCode(HttpStatus.OK)
@Authenticated()
async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.verifyPinCode(auth, dto);
async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise<void> {
return this.service.unlockSession(auth, dto);
}
@Post('session/lock')
@HttpCode(HttpStatus.OK)
@Authenticated()
async lockAuthSession(@Auth() auth: AuthDto): Promise<void> {
return this.service.lockSession(auth);
}
}

View file

@ -37,4 +37,11 @@ export class SessionController {
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Post(':id/lock')
@Authenticated({ permission: Permission.SESSION_LOCK })
@HttpCode(HttpStatus.NO_CONTENT)
lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.lock(auth, id);
}
}

View file

@ -232,6 +232,7 @@ export type Session = {
id: string;
createdAt: Date;
updatedAt: Date;
expiresAt: Date | null;
deviceOS: string;
deviceType: string;
pinExpiresAt: Date | null;

2
server/src/db.d.ts vendored
View file

@ -344,7 +344,7 @@ export interface Sessions {
deviceType: Generated<string>;
id: Generated<string>;
parentId: string | null;
expiredAt: Date | null;
expiresAt: Date | null;
token: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;

View file

@ -93,6 +93,8 @@ export class PinCodeResetDto {
password?: string;
}
export class SessionUnlockDto extends PinCodeResetDto {}
export class PinCodeChangeDto extends PinCodeResetDto {
@PinCode()
newPinCode!: string;
@ -139,4 +141,6 @@ export class AuthStatusResponseDto {
pinCode!: boolean;
password!: boolean;
isElevated!: boolean;
expiresAt?: string;
pinExpiresAt?: string;
}

View file

@ -24,6 +24,7 @@ export class SessionResponseDto {
id!: string;
createdAt!: string;
updatedAt!: string;
expiresAt?: string;
current!: boolean;
deviceType!: string;
deviceOS!: string;
@ -37,6 +38,7 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
expiresAt: entity.expiresAt?.toISOString(),
current: currentId === entity.id,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,

View file

@ -148,6 +148,7 @@ export enum Permission {
SESSION_READ = 'session.read',
SESSION_UPDATE = 'session.update',
SESSION_DELETE = 'session.delete',
SESSION_LOCK = 'session.lock',
SHARED_LINK_CREATE = 'sharedLink.create',
SHARED_LINK_READ = 'sharedLink.read',

View file

@ -199,6 +199,15 @@ where
"partners"."sharedById" in ($1)
and "partners"."sharedWithId" = $2
-- AccessRepository.session.checkOwnerAccess
select
"sessions"."id"
from
"sessions"
where
"sessions"."id" in ($1)
and "sessions"."userId" = $2
-- AccessRepository.stack.checkOwnerAccess
select
"stacks"."id"

View file

@ -1,12 +1,14 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SessionRepository.search
-- SessionRepository.get
select
*
"id",
"expiresAt",
"pinExpiresAt"
from
"sessions"
where
"sessions"."updatedAt" <= $1
"id" = $1
-- SessionRepository.getByToken
select
@ -37,8 +39,8 @@ from
where
"sessions"."token" = $1
and (
"sessions"."expiredAt" is null
or "sessions"."expiredAt" > $2
"sessions"."expiresAt" is null
or "sessions"."expiresAt" > $2
)
-- SessionRepository.getByUserId
@ -50,6 +52,10 @@ from
and "users"."deletedAt" is null
where
"sessions"."userId" = $1
and (
"sessions"."expiresAt" is null
or "sessions"."expiresAt" > $2
)
order by
"sessions"."updatedAt" desc,
"sessions"."createdAt" desc
@ -58,3 +64,10 @@ order by
delete from "sessions"
where
"id" = $1::uuid
-- SessionRepository.lockAll
update "sessions"
set
"pinExpiresAt" = $1
where
"userId" = $2

View file

@ -306,6 +306,25 @@ class NotificationAccess {
}
}
class SessionAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, sessionIds: Set<string>) {
if (sessionIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('sessions')
.select('sessions.id')
.where('sessions.id', 'in', [...sessionIds])
.where('sessions.userId', '=', userId)
.execute()
.then((sessions) => new Set(sessions.map((session) => session.id)));
}
}
class StackAccess {
constructor(private db: Kysely<DB>) {}
@ -456,6 +475,7 @@ export class AccessRepository {
notification: NotificationAccess;
person: PersonAccess;
partner: PartnerAccess;
session: SessionAccess;
stack: StackAccess;
tag: TagAccess;
timeline: TimelineAccess;
@ -469,6 +489,7 @@ export class AccessRepository {
this.notification = new NotificationAccess(db);
this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db);
this.session = new SessionAccess(db);
this.stack = new StackAccess(db);
this.tag = new TagAccess(db);
this.timeline = new TimelineAccess(db);

View file

@ -20,20 +20,20 @@ export class SessionRepository {
.where((eb) =>
eb.or([
eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()),
eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]),
eb.and([eb('expiresAt', 'is not', null), eb('expiresAt', '<=', DateTime.now().toJSDate())]),
]),
)
.returning(['id', 'deviceOS', 'deviceType'])
.execute();
}
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
search(options: SessionSearchOptions) {
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string) {
return this.db
.selectFrom('sessions')
.selectAll()
.where('sessions.updatedAt', '<=', options.updatedBefore)
.execute();
.select(['id', 'expiresAt', 'pinExpiresAt'])
.where('id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.STRING] })
@ -52,7 +52,7 @@ export class SessionRepository {
])
.where('sessions.token', '=', token)
.where((eb) =>
eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]),
eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]),
)
.executeTakeFirst();
}
@ -64,6 +64,9 @@ export class SessionRepository {
.innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null))
.selectAll('sessions')
.where('sessions.userId', '=', userId)
.where((eb) =>
eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]),
)
.orderBy('sessions.updatedAt', 'desc')
.orderBy('sessions.createdAt', 'desc')
.execute();
@ -86,4 +89,9 @@ export class SessionRepository {
async delete(id: string) {
await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async lockAll(userId: string) {
await this.db.updateTable('sessions').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();
}
}

View file

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" RENAME "expiredAt" TO "expiresAt";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" RENAME "expiresAt" TO "expiredAt";`.execute(db);
}

View file

@ -26,7 +26,7 @@ export class SessionTable {
updatedAt!: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
expiredAt!: Date | null;
expiresAt!: Date | null;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;

View file

@ -924,13 +924,13 @@ describe(AuthService.name, () => {
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
mocks.session.getByUserId.mockResolvedValue([currentSession]);
mocks.session.lockAll.mockResolvedValue(void 0);
mocks.session.update.mockResolvedValue(currentSession);
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null });
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
});
it('should throw if the PIN code does not match', async () => {

View file

@ -18,6 +18,7 @@ import {
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
SessionUnlockDto,
SignUpDto,
mapLoginResponse,
} from 'src/dtos/auth.dto';
@ -123,24 +124,21 @@ export class AuthService extends BaseService {
async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
this.validatePinCode(user, dto);
await this.userRepository.update(auth.user.id, { pinCode: null });
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
for (const session of sessions) {
await this.sessionRepository.update(session.id, { pinExpiresAt: null });
}
await this.sessionRepository.lockAll(auth.user.id);
}
async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
this.validatePinCode(user, dto);
const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS);
await this.userRepository.update(auth.user.id, { pinCode: hashed });
}
private resetPinChecks(
private validatePinCode(
user: { pinCode: string | null; password: string | null },
dto: { pinCode?: string; password?: string },
) {
@ -474,23 +472,27 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Invalid user token');
}
async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise<void> {
const user = await this.userRepository.getForPinCode(auth.user.id);
if (!user) {
throw new UnauthorizedException();
}
this.resetPinChecks(user, { pinCode: dto.pinCode });
async unlockSession(auth: AuthDto, dto: SessionUnlockDto): Promise<void> {
if (!auth.session) {
throw new BadRequestException('Session is missing');
throw new BadRequestException('This endpoint can only be used with a session token');
}
const user = await this.userRepository.getForPinCode(auth.user.id);
this.validatePinCode(user, { pinCode: dto.pinCode });
await this.sessionRepository.update(auth.session.id, {
pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()),
pinExpiresAt: DateTime.now().plus({ minutes: 15 }).toJSDate(),
});
}
async lockSession(auth: AuthDto): Promise<void> {
if (!auth.session) {
throw new BadRequestException('This endpoint can only be used with a session token');
}
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
@ -526,10 +528,14 @@ export class AuthService extends BaseService {
throw new UnauthorizedException();
}
const session = auth.session ? await this.sessionRepository.get(auth.session.id) : undefined;
return {
pinCode: !!user.pinCode,
password: !!user.password,
isElevated: !!auth.session?.hasElevatedPermission,
expiresAt: session?.expiresAt?.toISOString(),
pinExpiresAt: session?.pinExpiresAt?.toISOString(),
};
}
}

View file

@ -30,7 +30,7 @@ export class SessionService extends BaseService {
const session = await this.sessionRepository.create({
parentId: auth.session.id,
userId: auth.user.id,
expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
deviceType: dto.deviceType,
deviceOS: dto.deviceOS,
token: tokenHashed,
@ -49,6 +49,11 @@ export class SessionService extends BaseService {
await this.sessionRepository.delete(id);
}
async lock(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.SESSION_LOCK, ids: [id] });
await this.sessionRepository.update(id, { pinExpiresAt: null });
}
async deleteAll(auth: AuthDto): Promise<void> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
for (const session of sessions) {

View file

@ -280,6 +280,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return await access.partner.checkUpdateAccess(auth.user.id, ids);
}
case Permission.SESSION_READ:
case Permission.SESSION_UPDATE:
case Permission.SESSION_DELETE:
case Permission.SESSION_LOCK: {
return access.session.checkOwnerAccess(auth.user.id, ids);
}
case Permission.STACK_READ: {
return access.stack.checkOwnerAccess(auth.user.id, ids);
}

View file

@ -50,6 +50,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()),
},
session: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
stack: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},

View file

@ -127,7 +127,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
deviceType: 'mobile',
token: 'abc123',
parentId: null,
expiredAt: null,
expiresAt: null,
userId: newUuid(),
pinExpiresAt: newDate(),
...session,