refactor(server): auth dto (#5593)

* refactor: AuthUserDto => AuthDto

* refactor: reorganize auth-dto

* refactor: AuthUser() => Auth()
This commit is contained in:
Jason Rasmussen
2023-12-09 23:34:12 -05:00
committed by GitHub
parent 8057c375ba
commit 33529d1d9b
60 changed files with 1033 additions and 1065 deletions
+7 -12
View File
@@ -1,19 +1,14 @@
import { UserEntity, UserTokenEntity } from '@app/infra/entities';
import { APIKeyEntity, SharedLinkEntity, UserEntity, UserTokenEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class AuthUserDto {
id!: string;
email!: string;
isAdmin!: boolean;
isPublicUser?: boolean;
sharedLinkId?: string;
isAllowUpload?: boolean;
isAllowDownload?: boolean;
isShowMetadata?: boolean;
accessTokenId?: string;
externalPath?: string | null;
export class AuthDto {
user!: UserEntity;
apiKey?: APIKeyEntity;
sharedLink?: SharedLinkEntity;
userToken?: UserTokenEntity;
}
export class LoginCredentialDto {
+44 -29
View File
@@ -31,7 +31,7 @@ import {
IUserTokenRepository,
} from '../repositories';
import { AuthType } from './auth.constant';
import { AuthUserDto, SignUpDto } from './auth.dto';
import { AuthDto, SignUpDto } from './auth.dto';
import { AuthService } from './auth.service';
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
@@ -145,7 +145,7 @@ describe('AuthService', () => {
describe('changePassword', () => {
it('should change the password', async () => {
const authUser = { email: 'test@imimch.com' } as UserEntity;
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' };
userMock.getByEmail.mockResolvedValue({
@@ -153,23 +153,23 @@ describe('AuthService', () => {
password: 'hash-password',
} as UserEntity);
await sut.changePassword(authUser, dto);
await sut.changePassword(auth, dto);
expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true);
expect(userMock.getByEmail).toHaveBeenCalledWith(auth.user.email, true);
expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
});
it('should throw when auth user email is not found', async () => {
const authUser = { email: 'test@imimch.com' } as UserEntity;
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' };
userMock.getByEmail.mockResolvedValue(null);
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should throw when password does not match existing password', async () => {
const authUser = { email: 'test@imimch.com' } as UserEntity;
const auth = { user: { email: 'test@imimch.com' } as UserEntity };
const dto = { password: 'old-password', newPassword: 'new-password' };
cryptoMock.compareBcrypt.mockReturnValue(false);
@@ -179,11 +179,11 @@ describe('AuthService', () => {
password: 'hash-password',
} as UserEntity);
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
});
it('should throw when user does not have a password', async () => {
const authUser = { email: 'test@imimch.com' } as UserEntity;
const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' };
userMock.getByEmail.mockResolvedValue({
@@ -191,33 +191,33 @@ describe('AuthService', () => {
password: '',
} as UserEntity);
await expect(sut.changePassword(authUser, dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('logout', () => {
it('should return the end session endpoint', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
const authUser = { id: '123' } as AuthUserDto;
await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
const auth = { user: { id: '123' } } as AuthDto;
await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
});
});
it('should return the default redirect', async () => {
const authUser = { id: '123' } as AuthUserDto;
const auth = { user: { id: '123' } } as AuthDto;
await expect(sut.logout(authUser, AuthType.PASSWORD)).resolves.toEqual({
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
it('should delete the access token', async () => {
const authUser = { id: '123', accessTokenId: 'token123' } as AuthUserDto;
const auth = { user: { id: '123' }, userToken: { id: 'token123' } } as AuthDto;
await expect(sut.logout(authUser, AuthType.PASSWORD)).resolves.toEqual({
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
@@ -226,9 +226,9 @@ describe('AuthService', () => {
});
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
const authUser = { id: '123' } as AuthUserDto;
const auth = { user: { id: '123' } } as AuthDto;
await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
@@ -268,7 +268,10 @@ describe('AuthService', () => {
userMock.get.mockResolvedValue(userStub.user1);
userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken);
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userStub.user1);
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
});
});
});
@@ -296,7 +299,10 @@ describe('AuthService', () => {
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
userMock.get.mockResolvedValue(userStub.admin);
const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') };
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.adminSharedLink);
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.admin,
sharedLink: sharedLinkStub.valid,
});
expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
});
@@ -304,7 +310,10 @@ describe('AuthService', () => {
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
userMock.get.mockResolvedValue(userStub.admin);
const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') };
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.adminSharedLink);
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.admin,
sharedLink: sharedLinkStub.valid,
});
expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
});
});
@@ -319,14 +328,20 @@ describe('AuthService', () => {
it('should return an auth dto', async () => {
userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual(userStub.user1);
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
});
});
it('should update when access time exceeds an hour', async () => {
userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken);
userTokenMock.save.mockResolvedValue(userTokenStub.userToken);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual(userStub.user1);
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
});
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
id: 'not_active',
token: 'auth_token',
@@ -350,7 +365,7 @@ describe('AuthService', () => {
it('should return an auth dto', async () => {
keyMock.getKey.mockResolvedValue(keyStub.admin);
const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.admin);
await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.admin });
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
});
});
@@ -377,7 +392,7 @@ describe('AuthService', () => {
},
]);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
@@ -387,7 +402,7 @@ describe('AuthService', () => {
await sut.logoutDevices(authStub.user1);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
expect(userTokenMock.delete).toHaveBeenCalledWith('not_active');
expect(userTokenMock.delete).not.toHaveBeenCalledWith('token-id');
});
@@ -399,7 +414,7 @@ describe('AuthService', () => {
await sut.logoutDevice(authStub.user1, 'token-1');
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['token-1']));
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1']));
expect(userTokenMock.delete).toHaveBeenCalledWith('token-1');
});
});
@@ -506,7 +521,7 @@ describe('AuthService', () => {
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub });
});
it('should not link an already linked oauth.sub', async () => {
@@ -528,7 +543,7 @@ describe('AuthService', () => {
await sut.unlink(authStub.user1);
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' });
});
});
});
+37 -62
View File
@@ -34,7 +34,7 @@ import {
} from './auth.constant';
import {
AuthDeviceResponseDto,
AuthUserDto,
AuthDto,
ChangePasswordDto,
LoginCredentialDto,
LoginResponseDto,
@@ -110,9 +110,9 @@ export class AuthService {
return this.createLoginResponse(user, AuthType.PASSWORD, details);
}
async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
if (authUser.accessTokenId) {
await this.userTokenRepository.delete(authUser.accessTokenId);
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
if (auth.userToken) {
await this.userTokenRepository.delete(auth.userToken.id);
}
return {
@@ -121,9 +121,9 @@ export class AuthService {
};
}
async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
async changePassword(auth: AuthDto, dto: ChangePasswordDto) {
const { password, newPassword } = dto;
const user = await this.userRepository.getByEmail(authUser.email, true);
const user = await this.userRepository.getByEmail(auth.user.email, true);
if (!user) {
throw new UnauthorizedException();
}
@@ -133,7 +133,7 @@ export class AuthService {
throw new BadRequestException('Wrong password');
}
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
return this.userCore.updateUser(auth.user, auth.user.id, { password: newPassword });
}
async adminSignUp(dto: SignUpDto): Promise<UserResponseDto> {
@@ -154,7 +154,7 @@ export class AuthService {
return mapUser(admin);
}
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto> {
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
const userToken = (headers['x-immich-user-token'] ||
params.userToken ||
@@ -177,20 +177,20 @@ export class AuthService {
throw new UnauthorizedException('Authentication required');
}
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
const userTokens = await this.userTokenRepository.getAll(authUser.id);
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
async getDevices(auth: AuthDto): Promise<AuthDeviceResponseDto[]> {
const userTokens = await this.userTokenRepository.getAll(auth.user.id);
return userTokens.map((userToken) => mapUserToken(userToken, auth.userToken?.id));
}
async logoutDevice(authUser: AuthUserDto, id: string): Promise<void> {
await this.access.requirePermission(authUser, Permission.AUTH_DEVICE_DELETE, id);
async logoutDevice(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id);
await this.userTokenRepository.delete(id);
}
async logoutDevices(authUser: AuthUserDto): Promise<void> {
const devices = await this.userTokenRepository.getAll(authUser.id);
async logoutDevices(auth: AuthDto): Promise<void> {
const devices = await this.userTokenRepository.getAll(auth.user.id);
for (const device of devices) {
if (device.id === authUser.accessTokenId) {
if (device.id === auth.userToken?.id) {
continue;
}
await this.userTokenRepository.delete(device.id);
@@ -284,19 +284,19 @@ export class AuthService {
return this.createLoginResponse(user, AuthType.OAUTH, loginDetails);
}
async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
const config = await this.configCore.getConfig();
const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== user.id) {
if (duplicate && duplicate.id !== auth.user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
return mapUser(await this.userRepository.update(user.id, { oauthId }));
return mapUser(await this.userRepository.update(auth.user.id, { oauthId }));
}
async unlink(user: AuthUserDto): Promise<UserResponseDto> {
return mapUser(await this.userRepository.update(user.id, { oauthId: '' }));
async unlink(auth: AuthDto): Promise<UserResponseDto> {
return mapUser(await this.userRepository.update(auth.user.id, { oauthId: '' }));
}
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
@@ -371,45 +371,27 @@ export class AuthService {
return cookies[IMMICH_ACCESS_COOKIE] || null;
}
private async validateSharedLink(key: string | string[]): Promise<AuthUserDto> {
private async validateSharedLink(key: string | string[]): Promise<AuthDto> {
key = Array.isArray(key) ? key[0] : key;
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
const link = await this.sharedLinkRepository.getByKey(bytes);
if (link) {
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
const user = link.user;
const sharedLink = await this.sharedLinkRepository.getByKey(bytes);
if (sharedLink) {
if (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date()) {
const user = sharedLink.user;
if (user) {
return {
id: user.id,
email: user.email,
isAdmin: user.isAdmin,
isPublicUser: true,
sharedLinkId: link.id,
isAllowUpload: link.allowUpload,
isAllowDownload: link.allowDownload,
isShowMetadata: link.showExif,
};
return { user, sharedLink };
}
}
}
throw new UnauthorizedException('Invalid share key');
}
private async validateApiKey(key: string): Promise<AuthUserDto> {
private async validateApiKey(key: string): Promise<AuthDto> {
const hashedKey = this.cryptoRepository.hashSha256(key);
const keyEntity = await this.keyRepository.getKey(hashedKey);
if (keyEntity?.user) {
const user = keyEntity.user;
return {
id: user.id,
email: user.email,
isAdmin: user.isAdmin,
isPublicUser: false,
isAllowUpload: true,
externalPath: user.externalPath,
};
const apiKey = await this.keyRepository.getKey(hashedKey);
if (apiKey?.user) {
return { user: apiKey.user, apiKey };
}
throw new UnauthorizedException('Invalid API key');
@@ -422,26 +404,19 @@ export class AuthService {
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
}
private async validateUserToken(tokenValue: string): Promise<AuthUserDto> {
private async validateUserToken(tokenValue: string): Promise<AuthDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
let token = await this.userTokenRepository.getByToken(hashedToken);
let userToken = await this.userTokenRepository.getByToken(hashedToken);
if (token?.user) {
if (userToken?.user) {
const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(token.updatedAt);
const updatedAt = DateTime.fromJSDate(userToken.updatedAt);
const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) {
token = await this.userTokenRepository.save({ ...token, updatedAt: new Date() });
userToken = await this.userTokenRepository.save({ ...userToken, updatedAt: new Date() });
}
return {
...token.user,
isPublicUser: false,
isAllowUpload: true,
isAllowDownload: true,
isShowMetadata: true,
accessTokenId: token.id,
};
return { user: userToken.user, userToken };
}
throw new UnauthorizedException('Invalid user token');