feat: lock auth session (#18322)

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 310 additions and 72 deletions

View file

@ -1142,6 +1142,7 @@
"location_picker_latitude_hint": "Enter your latitude here", "location_picker_latitude_hint": "Enter your latitude here",
"location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_error": "Enter a valid longitude",
"location_picker_longitude_hint": "Enter your longitude here", "location_picker_longitude_hint": "Enter your longitude here",
"lock": "Lock",
"locked_folder": "Locked Folder", "locked_folder": "Locked Folder",
"log_out": "Log out", "log_out": "Log out",
"log_out_all_devices": "Log Out All Devices", "log_out_all_devices": "Log Out All Devices",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2377,7 +2377,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/PinCodeChangeDto" "$ref": "#/components/schemas/PinCodeResetDto"
} }
} }
}, },
@ -2470,15 +2470,40 @@
] ]
} }
}, },
"/auth/pin-code/verify": { "/auth/session/lock": {
"post": { "post": {
"operationId": "verifyPinCode", "operationId": "lockAuthSession",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/session/unlock": {
"post": {
"operationId": "unlockAuthSession",
"parameters": [], "parameters": [],
"requestBody": { "requestBody": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/PinCodeSetupDto" "$ref": "#/components/schemas/SessionUnlockDto"
} }
} }
}, },
@ -5695,6 +5720,41 @@
] ]
} }
}, },
"/sessions/{id}/lock": {
"post": {
"operationId": "lockSession",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/shared-links": { "/shared-links": {
"get": { "get": {
"operationId": "getAllSharedLinks", "operationId": "getAllSharedLinks",
@ -9327,6 +9387,9 @@
}, },
"AuthStatusResponseDto": { "AuthStatusResponseDto": {
"properties": { "properties": {
"expiresAt": {
"type": "string"
},
"isElevated": { "isElevated": {
"type": "boolean" "type": "boolean"
}, },
@ -9335,6 +9398,9 @@
}, },
"pinCode": { "pinCode": {
"type": "boolean" "type": "boolean"
},
"pinExpiresAt": {
"type": "string"
} }
}, },
"required": [ "required": [
@ -11096,6 +11162,7 @@
"session.read", "session.read",
"session.update", "session.update",
"session.delete", "session.delete",
"session.lock",
"sharedLink.create", "sharedLink.create",
"sharedLink.read", "sharedLink.read",
"sharedLink.update", "sharedLink.update",
@ -11297,6 +11364,18 @@
], ],
"type": "object" "type": "object"
}, },
"PinCodeResetDto": {
"properties": {
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"type": "string"
}
},
"type": "object"
},
"PinCodeSetupDto": { "PinCodeSetupDto": {
"properties": { "properties": {
"pinCode": { "pinCode": {
@ -12109,6 +12188,9 @@
"deviceType": { "deviceType": {
"type": "string" "type": "string"
}, },
"expiresAt": {
"type": "string"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -12144,6 +12226,9 @@
"deviceType": { "deviceType": {
"type": "string" "type": "string"
}, },
"expiresAt": {
"type": "string"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -12161,6 +12246,18 @@
], ],
"type": "object" "type": "object"
}, },
"SessionUnlockDto": {
"properties": {
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"type": "string"
}
},
"type": "object"
},
"SharedLinkCreateDto": { "SharedLinkCreateDto": {
"properties": { "properties": {
"albumId": { "albumId": {

View file

@ -512,18 +512,28 @@ export type LogoutResponseDto = {
redirectUri: string; redirectUri: string;
successful: boolean; successful: boolean;
}; };
export type PinCodeChangeDto = { export type PinCodeResetDto = {
newPinCode: string;
password?: string; password?: string;
pinCode?: string; pinCode?: string;
}; };
export type PinCodeSetupDto = { export type PinCodeSetupDto = {
pinCode: string; pinCode: string;
}; };
export type PinCodeChangeDto = {
newPinCode: string;
password?: string;
pinCode?: string;
};
export type SessionUnlockDto = {
password?: string;
pinCode?: string;
};
export type AuthStatusResponseDto = { export type AuthStatusResponseDto = {
expiresAt?: string;
isElevated: boolean; isElevated: boolean;
password: boolean; password: boolean;
pinCode: boolean; pinCode: boolean;
pinExpiresAt?: string;
}; };
export type ValidateAccessTokenResponseDto = { export type ValidateAccessTokenResponseDto = {
authStatus: boolean; authStatus: boolean;
@ -1075,6 +1085,7 @@ export type SessionResponseDto = {
current: boolean; current: boolean;
deviceOS: string; deviceOS: string;
deviceType: string; deviceType: string;
expiresAt?: string;
id: string; id: string;
updatedAt: string; updatedAt: string;
}; };
@ -1089,6 +1100,7 @@ export type SessionCreateResponseDto = {
current: boolean; current: boolean;
deviceOS: string; deviceOS: string;
deviceType: string; deviceType: string;
expiresAt?: string;
id: string; id: string;
token: string; token: string;
updatedAt: string; updatedAt: string;
@ -2066,13 +2078,13 @@ export function logout(opts?: Oazapfts.RequestOpts) {
method: "POST" method: "POST"
})); }));
} }
export function resetPinCode({ pinCodeChangeDto }: { export function resetPinCode({ pinCodeResetDto }: {
pinCodeChangeDto: PinCodeChangeDto; pinCodeResetDto: PinCodeResetDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({ return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts, ...opts,
method: "DELETE", method: "DELETE",
body: pinCodeChangeDto body: pinCodeResetDto
}))); })));
} }
export function setupPinCode({ pinCodeSetupDto }: { export function setupPinCode({ pinCodeSetupDto }: {
@ -2093,13 +2105,19 @@ export function changePinCode({ pinCodeChangeDto }: {
body: pinCodeChangeDto body: pinCodeChangeDto
}))); })));
} }
export function verifyPinCode({ pinCodeSetupDto }: { export function lockAuthSession(opts?: Oazapfts.RequestOpts) {
pinCodeSetupDto: PinCodeSetupDto; return oazapfts.ok(oazapfts.fetchText("/auth/session/lock", {
...opts,
method: "POST"
}));
}
export function unlockAuthSession({ sessionUnlockDto }: {
sessionUnlockDto: SessionUnlockDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({ return oazapfts.ok(oazapfts.fetchText("/auth/session/unlock", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
body: pinCodeSetupDto body: sessionUnlockDto
}))); })));
} }
export function getAuthStatus(opts?: Oazapfts.RequestOpts) { export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
@ -2952,6 +2970,14 @@ export function deleteSession({ id }: {
method: "DELETE" method: "DELETE"
})); }));
} }
export function lockSession({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}/lock`, {
...opts,
method: "POST"
}));
}
export function getAllSharedLinks({ albumId }: { export function getAllSharedLinks({ albumId }: {
albumId?: string; albumId?: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
@ -3709,6 +3735,7 @@ export enum Permission {
SessionRead = "session.read", SessionRead = "session.read",
SessionUpdate = "session.update", SessionUpdate = "session.update",
SessionDelete = "session.delete", SessionDelete = "session.delete",
SessionLock = "session.lock",
SharedLinkCreate = "sharedLink.create", SharedLinkCreate = "sharedLink.create",
SharedLinkRead = "sharedLink.read", SharedLinkRead = "sharedLink.read",
SharedLinkUpdate = "sharedLink.update", SharedLinkUpdate = "sharedLink.update",

View file

@ -9,7 +9,9 @@ import {
LoginResponseDto, LoginResponseDto,
LogoutResponseDto, LogoutResponseDto,
PinCodeChangeDto, PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto, PinCodeSetupDto,
SessionUnlockDto,
SignUpDto, SignUpDto,
ValidateAccessTokenResponseDto, ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
@ -98,14 +100,21 @@ export class AuthController {
@Delete('pin-code') @Delete('pin-code')
@Authenticated() @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); return this.service.resetPinCode(auth, dto);
} }
@Post('pin-code/verify') @Post('session/unlock')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Authenticated() @Authenticated()
async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> { async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise<void> {
return this.service.verifyPinCode(auth, dto); 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> { deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id); 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; id: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
expiresAt: Date | null;
deviceOS: string; deviceOS: string;
deviceType: string; deviceType: string;
pinExpiresAt: Date | null; pinExpiresAt: Date | null;

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,14 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- SessionRepository.search -- SessionRepository.get
select select
* "id",
"expiresAt",
"pinExpiresAt"
from from
"sessions" "sessions"
where where
"sessions"."updatedAt" <= $1 "id" = $1
-- SessionRepository.getByToken -- SessionRepository.getByToken
select select
@ -37,8 +39,8 @@ from
where where
"sessions"."token" = $1 "sessions"."token" = $1
and ( and (
"sessions"."expiredAt" is null "sessions"."expiresAt" is null
or "sessions"."expiredAt" > $2 or "sessions"."expiresAt" > $2
) )
-- SessionRepository.getByUserId -- SessionRepository.getByUserId
@ -50,6 +52,10 @@ from
and "users"."deletedAt" is null and "users"."deletedAt" is null
where where
"sessions"."userId" = $1 "sessions"."userId" = $1
and (
"sessions"."expiresAt" is null
or "sessions"."expiresAt" > $2
)
order by order by
"sessions"."updatedAt" desc, "sessions"."updatedAt" desc,
"sessions"."createdAt" desc "sessions"."createdAt" desc
@ -58,3 +64,10 @@ order by
delete from "sessions" delete from "sessions"
where where
"id" = $1::uuid "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 { class StackAccess {
constructor(private db: Kysely<DB>) {} constructor(private db: Kysely<DB>) {}
@ -456,6 +475,7 @@ export class AccessRepository {
notification: NotificationAccess; notification: NotificationAccess;
person: PersonAccess; person: PersonAccess;
partner: PartnerAccess; partner: PartnerAccess;
session: SessionAccess;
stack: StackAccess; stack: StackAccess;
tag: TagAccess; tag: TagAccess;
timeline: TimelineAccess; timeline: TimelineAccess;
@ -469,6 +489,7 @@ export class AccessRepository {
this.notification = new NotificationAccess(db); this.notification = new NotificationAccess(db);
this.person = new PersonAccess(db); this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db); this.partner = new PartnerAccess(db);
this.session = new SessionAccess(db);
this.stack = new StackAccess(db); this.stack = new StackAccess(db);
this.tag = new TagAccess(db); this.tag = new TagAccess(db);
this.timeline = new TimelineAccess(db); this.timeline = new TimelineAccess(db);

View file

@ -20,20 +20,20 @@ export class SessionRepository {
.where((eb) => .where((eb) =>
eb.or([ eb.or([
eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()), 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']) .returning(['id', 'deviceOS', 'deviceType'])
.execute(); .execute();
} }
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) @GenerateSql({ params: [DummyValue.UUID] })
search(options: SessionSearchOptions) { get(id: string) {
return this.db return this.db
.selectFrom('sessions') .selectFrom('sessions')
.selectAll() .select(['id', 'expiresAt', 'pinExpiresAt'])
.where('sessions.updatedAt', '<=', options.updatedBefore) .where('id', '=', id)
.execute(); .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
@ -52,7 +52,7 @@ export class SessionRepository {
]) ])
.where('sessions.token', '=', token) .where('sessions.token', '=', token)
.where((eb) => .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(); .executeTakeFirst();
} }
@ -64,6 +64,9 @@ export class SessionRepository {
.innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null)) .innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null))
.selectAll('sessions') .selectAll('sessions')
.where('sessions.userId', '=', userId) .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.updatedAt', 'desc')
.orderBy('sessions.createdAt', 'desc') .orderBy('sessions.createdAt', 'desc')
.execute(); .execute();
@ -86,4 +89,9 @@ export class SessionRepository {
async delete(id: string) { async delete(id: string) {
await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute(); 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; updatedAt!: Date;
@Column({ type: 'timestamp with time zone', nullable: true }) @Column({ type: 'timestamp with time zone', nullable: true })
expiredAt!: Date | null; expiresAt!: Date | null;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string; userId!: string;

View file

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

View file

@ -18,6 +18,7 @@ import {
PinCodeChangeDto, PinCodeChangeDto,
PinCodeResetDto, PinCodeResetDto,
PinCodeSetupDto, PinCodeSetupDto,
SessionUnlockDto,
SignUpDto, SignUpDto,
mapLoginResponse, mapLoginResponse,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
@ -123,24 +124,21 @@ export class AuthService extends BaseService {
async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) { async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) {
const user = await this.userRepository.getForPinCode(auth.user.id); 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 }); await this.userRepository.update(auth.user.id, { pinCode: null });
const sessions = await this.sessionRepository.getByUserId(auth.user.id); await this.sessionRepository.lockAll(auth.user.id);
for (const session of sessions) {
await this.sessionRepository.update(session.id, { pinExpiresAt: null });
}
} }
async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) { async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
const user = await this.userRepository.getForPinCode(auth.user.id); 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); const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS);
await this.userRepository.update(auth.user.id, { pinCode: hashed }); await this.userRepository.update(auth.user.id, { pinCode: hashed });
} }
private resetPinChecks( private validatePinCode(
user: { pinCode: string | null; password: string | null }, user: { pinCode: string | null; password: string | null },
dto: { pinCode?: string; password?: string }, dto: { pinCode?: string; password?: string },
) { ) {
@ -474,23 +472,27 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Invalid user token'); throw new UnauthorizedException('Invalid user token');
} }
async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise<void> { async unlockSession(auth: AuthDto, dto: SessionUnlockDto): Promise<void> {
const user = await this.userRepository.getForPinCode(auth.user.id);
if (!user) {
throw new UnauthorizedException();
}
this.resetPinChecks(user, { pinCode: dto.pinCode });
if (!auth.session) { 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, { 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) { private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
const token = this.cryptoRepository.randomBytesAsText(32); const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token); const tokenHashed = this.cryptoRepository.hashSha256(token);
@ -526,10 +528,14 @@ export class AuthService extends BaseService {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const session = auth.session ? await this.sessionRepository.get(auth.session.id) : undefined;
return { return {
pinCode: !!user.pinCode, pinCode: !!user.pinCode,
password: !!user.password, password: !!user.password,
isElevated: !!auth.session?.hasElevatedPermission, 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({ const session = await this.sessionRepository.create({
parentId: auth.session.id, parentId: auth.session.id,
userId: auth.user.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, deviceType: dto.deviceType,
deviceOS: dto.deviceOS, deviceOS: dto.deviceOS,
token: tokenHashed, token: tokenHashed,
@ -49,6 +49,11 @@ export class SessionService extends BaseService {
await this.sessionRepository.delete(id); 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> { async deleteAll(auth: AuthDto): Promise<void> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id); const sessions = await this.sessionRepository.getByUserId(auth.user.id);
for (const session of sessions) { 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); 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: { case Permission.STACK_READ: {
return access.stack.checkOwnerAccess(auth.user.id, ids); return access.stack.checkOwnerAccess(auth.user.id, ids);
} }

View file

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

View file

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

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
@ -10,11 +11,12 @@
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { AssetVisibility } from '@immich/sdk'; import { AssetVisibility, lockAuthSession } from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js'; import { Button } from '@immich/ui';
import { mdiDotsVertical, mdiLockOutline } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@ -42,6 +44,11 @@
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
assetStore.removeAssets(assetIds); assetStore.removeAssets(assetIds);
}; };
const handleLock = async () => {
await lockAuthSession();
await goto(AppRoute.PHOTOS);
};
</script> </script>
<!-- Multi-selection mode app bar --> <!-- Multi-selection mode app bar -->
@ -62,6 +69,12 @@
{/if} {/if}
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}> <UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
{#snippet buttons()}
<Button size="small" variant="filled" color="warning" leadingIcon={mdiLockOutline} onclick={handleLock}>
{$t('lock')}
</Button>
{/snippet}
<AssetGrid <AssetGrid
enableRouting={true} enableRouting={true}
{assetStore} {assetStore}

View file

@ -8,14 +8,12 @@ import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => { export const load = (async ({ params, url }) => {
await authenticate(url); await authenticate(url);
const { isElevated, pinCode } = await getAuthStatus(); const { isElevated, pinCode } = await getAuthStatus();
if (!isElevated || !pinCode) { if (!isElevated || !pinCode) {
const continuePath = encodeURIComponent(url.pathname); redirect(302, `${AppRoute.AUTH_PIN_PROMPT}?continue=${encodeURIComponent(url.pathname + url.search)}`);
const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`;
redirect(302, redirectPath);
} }
const asset = await getAssetInfoFromParam(params); const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter(); const $t = await getFormatter();

View file

@ -3,9 +3,8 @@
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte'; import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte'; import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { verifyPinCode } from '@immich/sdk'; import { unlockAuthSession } from '@immich/sdk';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js'; import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -23,17 +22,15 @@
let hasPinCode = $derived(data.hasPinCode); let hasPinCode = $derived(data.hasPinCode);
let pinCode = $state(''); let pinCode = $state('');
const onPinFilled = async (code: string, withDelay = false) => { const handleUnlockSession = async (code: string) => {
try { try {
await verifyPinCode({ pinCodeSetupDto: { pinCode: code } }); await unlockAuthSession({ sessionUnlockDto: { pinCode: code } });
isVerified = true; isVerified = true;
if (withDelay) { await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000));
}
void goto(data.continuePath ?? AppRoute.LOCKED); await goto(data.continueUrl);
} catch (error) { } catch (error) {
handleError(error, $t('wrong_pin_code')); handleError(error, $t('wrong_pin_code'));
isBadPinCode = true; isBadPinCode = true;
@ -64,7 +61,7 @@
bind:value={pinCode} bind:value={pinCode}
tabindexStart={1} tabindexStart={1}
pinLength={6} pinLength={6}
onFilled={(pinCode) => onPinFilled(pinCode, true)} onFilled={handleUnlockSession}
/> />
</div> </div>
</div> </div>

View file

@ -1,3 +1,4 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth'; import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { getAuthStatus } from '@immich/sdk'; import { getAuthStatus } from '@immich/sdk';
@ -8,8 +9,6 @@ export const load = (async ({ url }) => {
const { pinCode } = await getAuthStatus(); const { pinCode } = await getAuthStatus();
const continuePath = url.searchParams.get('continue');
const $t = await getFormatter(); const $t = await getFormatter();
return { return {
@ -17,6 +16,6 @@ export const load = (async ({ url }) => {
title: $t('pin_verification'), title: $t('pin_verification'),
}, },
hasPinCode: !!pinCode, hasPinCode: !!pinCode,
continuePath, continueUrl: url.searchParams.get('continue') || AppRoute.LOCKED,
}; };
}) satisfies PageLoad; }) satisfies PageLoad;