mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 04:09:07 +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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user