mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(web,server): manage authorized devices (#2329)
* feat: manage authorized devices * chore: open api * get header from mobile app * write header from mobile app * styling * fix unit test * feat: use relative time * feat: update access time * fix: tests * chore: confirm wording * chore: bump test coverage thresholds * feat: add some icons * chore: icon tweaks --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AdminSignupResponseDto,
|
||||
AuthDeviceResponseDto,
|
||||
AuthService,
|
||||
AuthType,
|
||||
AuthUserDto,
|
||||
@@ -7,18 +8,20 @@ import {
|
||||
IMMICH_ACCESS_COOKIE,
|
||||
IMMICH_AUTH_TYPE_COOKIE,
|
||||
LoginCredentialDto,
|
||||
LoginDetails,
|
||||
LoginResponseDto,
|
||||
LogoutResponseDto,
|
||||
SignUpDto,
|
||||
UserResponseDto,
|
||||
ValidateAccessTokenResponseDto,
|
||||
} from '@app/domain';
|
||||
import { Body, Controller, Ip, Post, Req, Res } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
import { UseValidation } from '../decorators/use-validation.decorator';
|
||||
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
@@ -29,11 +32,10 @@ export class AuthController {
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() loginCredential: LoginCredentialDto,
|
||||
@Ip() clientIp: string,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<LoginResponseDto> {
|
||||
const { response, cookie } = await this.service.login(loginCredential, clientIp, req.secure);
|
||||
const { response, cookie } = await this.service.login(loginCredential, loginDetails);
|
||||
res.header('Set-Cookie', cookie);
|
||||
return response;
|
||||
}
|
||||
@@ -44,6 +46,18 @@ export class AuthController {
|
||||
return this.service.adminSignUp(signUpCredential);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Get('devices')
|
||||
getAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
|
||||
return this.service.getDevices(authUser);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Delete('devices/:id')
|
||||
logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.logoutDevice(authUser, id);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Post('validateToken')
|
||||
validateAccessToken(): ValidateAccessTokenResponseDto {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AuthUserDto,
|
||||
LoginDetails,
|
||||
LoginResponseDto,
|
||||
OAuthCallbackDto,
|
||||
OAuthConfigDto,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
import { UseValidation } from '../decorators/use-validation.decorator';
|
||||
|
||||
@@ -38,9 +39,9 @@ export class OAuthController {
|
||||
async callback(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Body() dto: OAuthCallbackDto,
|
||||
@Req() req: Request,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<LoginResponseDto> {
|
||||
const { response, cookie } = await this.service.login(dto, req.secure);
|
||||
const { response, cookie } = await this.service.login(dto, loginDetails);
|
||||
res.header('Set-Cookie', cookie);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
export { AuthUserDto } from '@app/domain';
|
||||
import { AuthUserDto } from '@app/domain';
|
||||
import { AuthUserDto, LoginDetails } from '@app/domain';
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
|
||||
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
|
||||
});
|
||||
|
||||
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
const userAgent = UAParser(req.headers['user-agent']);
|
||||
|
||||
return {
|
||||
clientIp: req.clientIp,
|
||||
isSecure: req.secure,
|
||||
deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
|
||||
deviceOS: userAgent.os.name || req.headers.devicetype || '',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ export function patchOpenAPI(document: OpenAPIObject) {
|
||||
if (operation.summary === '') {
|
||||
delete operation.summary;
|
||||
}
|
||||
|
||||
if (operation.description === '') {
|
||||
delete operation.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,70 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/devices": {
|
||||
"get": {
|
||||
"operationId": "getAuthDevices",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AuthDeviceResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/devices/{id}": {
|
||||
"delete": {
|
||||
"operationId": "logoutAuthDevice",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/validateToken": {
|
||||
"post": {
|
||||
"operationId": "validateAccessToken",
|
||||
@@ -3986,6 +4050,37 @@
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"AuthDeviceResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"current": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"deviceType": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceOS": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"current",
|
||||
"deviceType",
|
||||
"deviceOS"
|
||||
]
|
||||
},
|
||||
"ValidateAccessTokenResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4018,12 +4113,10 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"successful": {
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
"type": "boolean"
|
||||
},
|
||||
"redirectUri": {
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
|
||||
export interface LoginDetails {
|
||||
isSecure: boolean;
|
||||
clientIp: string;
|
||||
deviceType: string;
|
||||
deviceOS: string;
|
||||
}
|
||||
|
||||
export class AuthCore {
|
||||
private userTokenCore: UserTokenCore;
|
||||
@@ -23,7 +30,7 @@ export class AuthCore {
|
||||
return this.config.passwordLogin.enabled;
|
||||
}
|
||||
|
||||
public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
|
||||
getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
|
||||
const maxAge = 400 * 24 * 3600; // 400 days
|
||||
|
||||
let authTypeCookie = '';
|
||||
@@ -39,10 +46,10 @@ export class AuthCore {
|
||||
return [accessTokenCookie, authTypeCookie];
|
||||
}
|
||||
|
||||
public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
|
||||
const accessToken = await this.userTokenCore.createToken(user);
|
||||
async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
|
||||
const accessToken = await this.userTokenCore.create(user, loginDetails);
|
||||
const response = mapLoginResponse(user, accessToken);
|
||||
const cookie = this.getCookies(response, authType, isSecure);
|
||||
const cookie = this.getCookies(response, authType, loginDetails);
|
||||
return { response, cookie };
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ import { AuthUserDto, SignUpDto } from './dto';
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
const loginDetails = {
|
||||
isSecure: true,
|
||||
clientIp: '127.0.0.1',
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
};
|
||||
|
||||
const fixtures = {
|
||||
login: {
|
||||
@@ -40,8 +46,6 @@ const fixtures = {
|
||||
},
|
||||
};
|
||||
|
||||
const CLIENT_IP = '127.0.0.1';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let sut: AuthService;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
@@ -96,32 +100,39 @@ describe('AuthService', () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
sut = create(systemConfigStub.disabled);
|
||||
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check the user exists', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should check the user has a password', async () => {
|
||||
userMock.getByEmail.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password);
|
||||
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should generate the cookie headers (insecure)', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
|
||||
await expect(
|
||||
sut.login(fixtures.login, {
|
||||
clientIp: '127.0.0.1',
|
||||
isSecure: false,
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
}),
|
||||
).resolves.toEqual(loginResponseStub.user1insecure);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -205,7 +216,7 @@ describe('AuthService', () => {
|
||||
redirectUri: '/auth/login?autoLaunch=0',
|
||||
});
|
||||
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith('token123');
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,7 +251,7 @@ describe('AuthService', () => {
|
||||
|
||||
it('should validate using authorization header', async () => {
|
||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
|
||||
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
@@ -276,16 +287,32 @@ describe('AuthService', () => {
|
||||
|
||||
describe('validate - user token', () => {
|
||||
it('should throw if no token is found', async () => {
|
||||
userTokenMock.get.mockResolvedValue(null);
|
||||
userTokenMock.getByToken.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
|
||||
it('should update when access time exceeds an hour', async () => {
|
||||
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.inactiveToken);
|
||||
userTokenMock.save.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
|
||||
id: 'not_active',
|
||||
token: 'auth_token',
|
||||
userId: 'immich_id',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: expect.any(Date),
|
||||
deviceOS: 'Android',
|
||||
deviceType: 'Mobile',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - api key', () => {
|
||||
@@ -303,4 +330,38 @@ describe('AuthService', () => {
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDevices', () => {
|
||||
it('should get the devices', async () => {
|
||||
userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.userToken, userTokenEntityStub.inactiveToken]);
|
||||
await expect(sut.getDevices(authStub.user1)).resolves.toEqual([
|
||||
{
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
current: true,
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
id: 'token-id',
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
{
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
current: false,
|
||||
deviceOS: 'Android',
|
||||
deviceType: 'Mobile',
|
||||
id: 'not_active',
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutDevice', () => {
|
||||
it('should logout the device', async () => {
|
||||
await sut.logoutDevice(authStub.user1, 'token-1');
|
||||
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { OAuthCore } from '../oauth/oauth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE } from './auth.constant';
|
||||
import { AuthCore } from './auth.core';
|
||||
import { AuthCore, LoginDetails } from './auth.core';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||
@@ -21,6 +21,7 @@ import cookieParser from 'cookie';
|
||||
import { ISharedLinkRepository, ShareCore } from '../share';
|
||||
import { APIKeyCore } from '../api-key/api-key.core';
|
||||
import { IKeyRepository } from '../api-key';
|
||||
import { AuthDeviceResponseDto, mapUserToken } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -53,8 +54,7 @@ export class AuthService {
|
||||
|
||||
public async login(
|
||||
loginCredential: LoginCredentialDto,
|
||||
clientIp: string,
|
||||
isSecure: boolean,
|
||||
loginDetails: LoginDetails,
|
||||
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||
if (!this.authCore.isPasswordLoginEnabled()) {
|
||||
throw new UnauthorizedException('Password login has been disabled');
|
||||
@@ -69,16 +69,18 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
|
||||
this.logger.warn(
|
||||
`Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
|
||||
);
|
||||
throw new BadRequestException('Incorrect email or password');
|
||||
}
|
||||
|
||||
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, isSecure);
|
||||
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
|
||||
}
|
||||
|
||||
public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||
if (authUser.accessTokenId) {
|
||||
await this.userTokenCore.deleteToken(authUser.accessTokenId);
|
||||
await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
|
||||
}
|
||||
|
||||
if (authType === AuthType.OAUTH) {
|
||||
@@ -152,6 +154,15 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
|
||||
const userTokens = await this.userTokenCore.getAll(authUser.id);
|
||||
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
|
||||
}
|
||||
|
||||
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
|
||||
await this.userTokenCore.delete(authUser.id, deviceId);
|
||||
}
|
||||
|
||||
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
||||
const [type, token] = (headers.authorization || '').split(' ');
|
||||
if (type.toLowerCase() === 'bearer') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './auth.constant';
|
||||
export * from './auth.core';
|
||||
export * from './auth.service';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { UserTokenEntity } from '@app/infra/entities';
|
||||
|
||||
export class AuthDeviceResponseDto {
|
||||
id!: string;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
current!: boolean;
|
||||
deviceType!: string;
|
||||
deviceOS!: string;
|
||||
}
|
||||
|
||||
export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({
|
||||
id: entity.id,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
current: currentId === entity.id,
|
||||
deviceOS: entity.deviceOS,
|
||||
deviceType: entity.deviceType,
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './admin-signup-response.dto';
|
||||
export * from './auth-device-response.dto';
|
||||
export * from './login-response.dto';
|
||||
export * from './logout-response.dto';
|
||||
export * from './validate-asset-token-response.dto';
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LogoutResponseDto {
|
||||
constructor(successful: boolean) {
|
||||
this.successful = successful;
|
||||
}
|
||||
|
||||
@ApiResponseProperty()
|
||||
successful!: boolean;
|
||||
|
||||
@ApiResponseProperty()
|
||||
redirectUri!: string;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ValidateAccessTokenResponseDto {
|
||||
constructor(authStatus: boolean) {
|
||||
this.authStatus = authStatus;
|
||||
}
|
||||
|
||||
@ApiProperty({ type: 'boolean' })
|
||||
authStatus!: boolean;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,16 @@ import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IUserTokenRepository } from '../user-token';
|
||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
||||
import { LoginDetails } from '../auth';
|
||||
|
||||
const email = 'user@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
const loginDetails: LoginDetails = {
|
||||
isSecure: true,
|
||||
clientIp: '127.0.0.1',
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
};
|
||||
|
||||
describe('OAuthService', () => {
|
||||
let sut: OAuthService;
|
||||
@@ -95,13 +102,13 @@ describe('OAuthService', () => {
|
||||
|
||||
describe('login', () => {
|
||||
it('should throw an error if OAuth is not enabled', async () => {
|
||||
await expect(sut.login({ url: '' }, true)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should not allow auto registering', async () => {
|
||||
sut = create(systemConfigStub.noAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).rejects.toBeInstanceOf(
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
@@ -113,7 +120,7 @@ describe('OAuthService', () => {
|
||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
);
|
||||
|
||||
@@ -129,7 +136,7 @@ describe('OAuthService', () => {
|
||||
userMock.create.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
|
||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
);
|
||||
|
||||
@@ -143,7 +150,7 @@ describe('OAuthService', () => {
|
||||
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
|
||||
await sut.login({ url: `app.immich:/?code=abc123` }, true);
|
||||
await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
|
||||
|
||||
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SystemConfig } from '@app/infra/entities';
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
|
||||
import { AuthCore } from '../auth/auth.core';
|
||||
import { AuthCore, LoginDetails } from '../auth/auth.core';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
@@ -39,7 +39,10 @@ export class OAuthService {
|
||||
return this.oauthCore.generateConfig(dto);
|
||||
}
|
||||
|
||||
async login(dto: OAuthCallbackDto, isSecure: boolean): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||
async login(
|
||||
dto: OAuthCallbackDto,
|
||||
loginDetails: LoginDetails,
|
||||
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||
const profile = await this.oauthCore.callback(dto.url);
|
||||
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
@@ -66,7 +69,7 @@ export class OAuthService {
|
||||
user = await this.userCore.createUser(this.oauthCore.asUser(profile));
|
||||
}
|
||||
|
||||
return this.authCore.createLoginResponse(user, AuthType.OAUTH, isSecure);
|
||||
return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
|
||||
}
|
||||
|
||||
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { UserEntity } from '@app/infra/entities';
|
||||
import { UserEntity, UserTokenEntity } from '@app/infra/entities';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { LoginDetails } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IUserTokenRepository } from './user-token.repository';
|
||||
|
||||
@@ -9,9 +11,16 @@ export class UserTokenCore {
|
||||
|
||||
async validate(tokenValue: string) {
|
||||
const hashedToken = this.crypto.hashSha256(tokenValue);
|
||||
const token = await this.repository.get(hashedToken);
|
||||
let token = await this.repository.getByToken(hashedToken);
|
||||
|
||||
if (token?.user) {
|
||||
const now = DateTime.now();
|
||||
const updatedAt = DateTime.fromJSDate(token.updatedAt);
|
||||
const diff = now.diff(updatedAt, ['hours']);
|
||||
if (diff.hours > 1) {
|
||||
token = await this.repository.save({ ...token, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
return {
|
||||
...token.user,
|
||||
isPublicUser: false,
|
||||
@@ -25,18 +34,24 @@ export class UserTokenCore {
|
||||
throw new UnauthorizedException('Invalid user token');
|
||||
}
|
||||
|
||||
public async createToken(user: UserEntity): Promise<string> {
|
||||
async create(user: UserEntity, loginDetails: LoginDetails): Promise<string> {
|
||||
const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
|
||||
const token = this.crypto.hashSha256(key);
|
||||
await this.repository.create({
|
||||
token,
|
||||
user,
|
||||
deviceOS: loginDetails.deviceOS,
|
||||
deviceType: loginDetails.deviceType,
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
public async deleteToken(id: string): Promise<void> {
|
||||
await this.repository.delete(id);
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.repository.delete(userId, id);
|
||||
}
|
||||
|
||||
getAll(userId: string): Promise<UserTokenEntity[]> {
|
||||
return this.repository.getAll(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ export const IUserTokenRepository = 'IUserTokenRepository';
|
||||
|
||||
export interface IUserTokenRepository {
|
||||
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||
delete(userToken: string): Promise<void>;
|
||||
save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||
delete(userId: string, id: string): Promise<void>;
|
||||
deleteAll(userId: string): Promise<void>;
|
||||
get(userToken: string): Promise<UserTokenEntity | null>;
|
||||
getByToken(token: string): Promise<UserTokenEntity | null>;
|
||||
getAll(userId: string): Promise<UserTokenEntity[]>;
|
||||
}
|
||||
|
||||
@@ -391,9 +391,22 @@ export const userTokenEntityStub = {
|
||||
userToken: Object.freeze<UserTokenEntity>({
|
||||
id: 'token-id',
|
||||
token: 'auth_token',
|
||||
userId: userEntityStub.user1.id,
|
||||
user: userEntityStub.user1,
|
||||
createdAt: '2021-01-01',
|
||||
updatedAt: '2021-01-01',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date(),
|
||||
deviceType: '',
|
||||
deviceOS: '',
|
||||
}),
|
||||
inactiveToken: Object.freeze<UserTokenEntity>({
|
||||
id: 'not_active',
|
||||
token: 'auth_token',
|
||||
userId: userEntityStub.user1.id,
|
||||
user: userEntityStub.user1,
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
deviceType: 'Mobile',
|
||||
deviceOS: 'Android',
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import { IUserTokenRepository } from '../src';
|
||||
export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository> => {
|
||||
return {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteAll: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getByToken: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,12 +9,21 @@ export class UserTokenEntity {
|
||||
@Column({ select: false })
|
||||
token!: string;
|
||||
|
||||
@Column()
|
||||
userId!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity)
|
||||
user!: UserEntity;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: string;
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: string;
|
||||
updatedAt!: Date;
|
||||
|
||||
@Column({ default: '' })
|
||||
deviceType!: string;
|
||||
|
||||
@Column({ default: '' })
|
||||
deviceOS!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class FixNullableRelations1682371561743 implements MigrationInterface {
|
||||
name = 'FixNullableRelations1682371561743';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" SET NOT NULL`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" DROP NOT NULL`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddDeviceInfoToUserToken1682371791038 implements MigrationInterface {
|
||||
name = 'AddDeviceInfoToUserToken1682371791038'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceType" character varying NOT NULL DEFAULT ''`);
|
||||
await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceOS" character varying NOT NULL DEFAULT ''`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceOS"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceType"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,24 +6,40 @@ import { IUserTokenRepository } from '@app/domain/user-token';
|
||||
|
||||
@Injectable()
|
||||
export class UserTokenRepository implements IUserTokenRepository {
|
||||
constructor(
|
||||
@InjectRepository(UserTokenEntity)
|
||||
private userTokenRepository: Repository<UserTokenEntity>,
|
||||
) {}
|
||||
constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
|
||||
|
||||
async get(userToken: string): Promise<UserTokenEntity | null> {
|
||||
return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } });
|
||||
getByToken(token: string): Promise<UserTokenEntity | null> {
|
||||
return this.repository.findOne({ where: { token }, relations: { user: true } });
|
||||
}
|
||||
|
||||
async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
||||
return this.userTokenRepository.save(userToken);
|
||||
getAll(userId: string): Promise<UserTokenEntity[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
order: {
|
||||
updatedAt: 'desc',
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.userTokenRepository.delete(id);
|
||||
create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
||||
return this.repository.save(userToken);
|
||||
}
|
||||
|
||||
save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
||||
return this.repository.save(userToken);
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.repository.delete({ userId, id });
|
||||
}
|
||||
|
||||
async deleteAll(userId: string): Promise<void> {
|
||||
await this.userTokenRepository.delete({ user: { id: userId } });
|
||||
await this.repository.delete({ userId });
|
||||
}
|
||||
}
|
||||
|
||||
41
server/package-lock.json
generated
41
server/package-lock.json
generated
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.53.0",
|
||||
"version": "1.54.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
@@ -48,7 +48,8 @@
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sharp": "^0.28.0",
|
||||
"typeorm": "^0.3.11",
|
||||
"typesense": "^1.5.3"
|
||||
"typesense": "^1.5.3",
|
||||
"ua-parser-js": "^1.0.35"
|
||||
},
|
||||
"bin": {
|
||||
"immich": "bin/cli.sh"
|
||||
@@ -73,6 +74,7 @@
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/sharp": "^0.30.2",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
"@typescript-eslint/parser": "^5.48.1",
|
||||
"dotenv": "^14.2.0",
|
||||
@@ -2852,6 +2854,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ua-parser-js": {
|
||||
"version": "0.7.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
|
||||
"integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
|
||||
@@ -11207,6 +11215,24 @@
|
||||
"@babel/runtime": "^7.17.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.35",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
|
||||
"integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.17.4",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
|
||||
@@ -13872,6 +13898,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/ua-parser-js": {
|
||||
"version": "0.7.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
|
||||
"integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/validator": {
|
||||
"version": "13.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
|
||||
@@ -20132,6 +20164,11 @@
|
||||
"loglevel": "^1.8.0"
|
||||
}
|
||||
},
|
||||
"ua-parser-js": {
|
||||
"version": "1.0.35",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
|
||||
"integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA=="
|
||||
},
|
||||
"uglify-js": {
|
||||
"version": "3.17.4",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sharp": "^0.28.0",
|
||||
"typeorm": "^0.3.11",
|
||||
"typesense": "^1.5.3"
|
||||
"typesense": "^1.5.3",
|
||||
"ua-parser-js": "^1.0.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.8",
|
||||
@@ -101,6 +102,7 @@
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/sharp": "^0.30.2",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
"@typescript-eslint/parser": "^5.48.1",
|
||||
"dotenv": "^14.2.0",
|
||||
@@ -139,9 +141,9 @@
|
||||
"coverageThreshold": {
|
||||
"./libs/domain/": {
|
||||
"branches": 80,
|
||||
"functions": 85,
|
||||
"lines": 90,
|
||||
"statements": 90
|
||||
"functions": 88,
|
||||
"lines": 94,
|
||||
"statements": 94
|
||||
}
|
||||
},
|
||||
"setupFilesAfterEnv": [
|
||||
@@ -158,4 +160,4 @@
|
||||
},
|
||||
"globalSetup": "<rootDir>/libs/domain/test/global-setup.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user