mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
feat(server): link via profile.sub (#1055)
This commit is contained in:
parent
424b11cf50
commit
99854e90be
@ -39,6 +39,7 @@ describe('AuthService', () => {
|
||||
userRepositoryMock = {
|
||||
get: jest.fn(),
|
||||
getAdmin: jest.fn(),
|
||||
getByOAuthId: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
create: jest.fn(),
|
||||
|
@ -20,12 +20,14 @@ const mockConfig = (config: Partial<OAuthConfig>) => {
|
||||
};
|
||||
|
||||
const email = 'user@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
|
||||
const user = {
|
||||
id: 'user',
|
||||
email,
|
||||
firstName: 'user',
|
||||
lastName: 'imimch',
|
||||
oauthId: '',
|
||||
} as UserEntity;
|
||||
|
||||
const loginResponse = {
|
||||
@ -53,13 +55,14 @@ describe('OAuthService', () => {
|
||||
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
|
||||
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
|
||||
callback: jest.fn().mockReturnValue({ access_token: 'access-token' }),
|
||||
userinfo: jest.fn().mockResolvedValue({ email }),
|
||||
userinfo: jest.fn().mockResolvedValue({ sub, email }),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
userRepositoryMock = {
|
||||
get: jest.fn(),
|
||||
getAdmin: jest.fn(),
|
||||
getByOAuthId: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
create: jest.fn(),
|
||||
@ -132,6 +135,26 @@ describe('OAuthService', () => {
|
||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
configServiceMock.get.mockImplementation(
|
||||
mockConfig({
|
||||
OAUTH_ENABLED: true,
|
||||
OAUTH_AUTO_REGISTER: false,
|
||||
}),
|
||||
);
|
||||
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
|
||||
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
|
||||
userRepositoryMock.getByEmail.mockResolvedValue(user);
|
||||
userRepositoryMock.update.mockResolvedValue(user);
|
||||
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
|
||||
|
||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true }));
|
||||
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||
|
@ -63,8 +63,17 @@ export class OAuthService {
|
||||
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
|
||||
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
let user = await this.userRepository.getByEmail(profile.email);
|
||||
let user = await this.userRepository.getByOAuthId(profile.sub);
|
||||
|
||||
// link existing user
|
||||
if (!user) {
|
||||
const emailUser = await this.userRepository.getByEmail(profile.email);
|
||||
if (emailUser) {
|
||||
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
|
||||
}
|
||||
}
|
||||
|
||||
// register new user
|
||||
if (!user) {
|
||||
if (!this.autoRegister) {
|
||||
this.logger.warn(
|
||||
@ -73,11 +82,12 @@ export class OAuthService {
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user: ${profile.email}`);
|
||||
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
|
||||
user = await this.userRepository.create({
|
||||
firstName: profile.given_name || '',
|
||||
lastName: profile.family_name || '',
|
||||
email: profile.email,
|
||||
oauthId: profile.sub,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ export interface IUserRepository {
|
||||
get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
|
||||
getAdmin(): Promise<UserEntity | null>;
|
||||
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
|
||||
getByOAuthId(oauthId: string): Promise<UserEntity | null>;
|
||||
getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
|
||||
create(user: Partial<UserEntity>): Promise<UserEntity>;
|
||||
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
|
||||
@ -41,6 +42,10 @@ export class UserRepository implements IUserRepository {
|
||||
return builder.getOne();
|
||||
}
|
||||
|
||||
public async getByOAuthId(oauthId: string): Promise<UserEntity | null> {
|
||||
return this.userRepository.findOne({ where: { oauthId } });
|
||||
}
|
||||
|
||||
public async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> {
|
||||
if (!excludeId) {
|
||||
return this.userRepository.find(); // TODO: this should also be ordered the same as below
|
||||
|
@ -27,6 +27,7 @@ describe('UserService', () => {
|
||||
firstName: 'admin_first_name',
|
||||
lastName: 'admin_last_name',
|
||||
isAdmin: true,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
@ -40,6 +41,7 @@ describe('UserService', () => {
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
isAdmin: false,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
@ -53,6 +55,7 @@ describe('UserService', () => {
|
||||
firstName: 'updated_immich_first_name',
|
||||
lastName: 'updated_immich_last_name',
|
||||
isAdmin: false,
|
||||
oauthId: '',
|
||||
shouldChangePassword: true,
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
|
@ -52,6 +52,7 @@ describe('ImmichJwtService', () => {
|
||||
email: 'test@immich.com',
|
||||
password: 'changeme',
|
||||
salt: '123',
|
||||
oauthId: '',
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: false,
|
||||
createdAt: 'today',
|
||||
|
@ -20,6 +20,7 @@ export function newUserRepositoryMock(): jest.Mocked<IUserRepository> {
|
||||
get: jest.fn(),
|
||||
getAdmin: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
getByOAuthId: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
|
@ -23,6 +23,9 @@ export class UserEntity {
|
||||
@Column({ default: '', select: false })
|
||||
salt?: string;
|
||||
|
||||
@Column({ default: '', select: false })
|
||||
oauthId!: string;
|
||||
|
||||
@Column({ default: '' })
|
||||
profileImagePath!: string;
|
||||
|
||||
|
14
server/libs/database/src/migrations/1670104716264-OAuthId.ts
Normal file
14
server/libs/database/src/migrations/1670104716264-OAuthId.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class OAuthId1670104716264 implements MigrationInterface {
|
||||
name = 'OAuthId1670104716264'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD "oauthId" character varying NOT NULL DEFAULT ''`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "oauthId"`);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user