1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-04 02:46:47 +01:00

feat(web,server): link/unlink oauth account (#1154)

* feat(web,server): link/unlink oauth account

* chore: linting

* fix: broken oauth callback

* fix: user core bugs

* fix: tests

* fix: use user response

* chore: update docs

* feat: prevent the same oauth account from being linked twice

* chore: mock logger
This commit is contained in:
Jason Rasmussen 2022-12-26 10:35:52 -05:00 committed by GitHub
parent ab0a3690f3
commit 7dc12dea1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 680 additions and 202 deletions

View file

@ -26,14 +26,33 @@ Before enabling OAuth in Immich, a new client application needs to be configured
The **Sign-in redirect URIs** should include: The **Sign-in redirect URIs** should include:
- All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`) - `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
- Mobile app redirect URL `app.immich:/` - `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
:::caution :::info Redirect URIs
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
Mobile
- `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly)
Localhost
- `http://localhost:2283/auth/login`
- `http://localhost:2283/user-settings`
Local IP
- `http://192.168.0.200:2283/auth/login`
- `http://192.168.0.200:2283/user-settings`
Hostname
- `https://immich.example.com/auth/login`)
- `https://immich.example.com/user-settings`)
**Authentik example**
<img src={require('./img/authentik-redirect.png').default} title="Authentik Redirection URL" width="80%" />
::: :::
## Enable OAuth ## Enable OAuth
@ -42,7 +61,7 @@ Once you have a new OAuth client application configured, Immich can be configure
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
| ------------- | ------- | -------------------- | ------------------------------------------------------------------------- | | ------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
| Enabled | boolean | false | Enable/disable OAuth | | Enabled | boolean | false | Enable/disable OAuth |
| Issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) | | Issuer URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
| Client ID | string | (required) | Required. Client ID (from previous step) | | Client ID | string | (required) | Required. Client ID (from previous step) |
| Client secret | string | (required) | Required. Client Secret (previous step) | | Client secret | string | (required) | Required. Client Secret (previous step) |

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.

View file

@ -74,9 +74,7 @@ export class AuthService {
throw new BadRequestException('Wrong password'); throw new BadRequestException('Wrong password');
} }
user.password = newPassword; return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
return this.userCore.updateUser(authUser, user, dto);
} }
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> { public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {

View file

@ -3,8 +3,10 @@ import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { AuthType } from '../../constants/jwt.constant'; import { AuthType } from '../../constants/jwt.constant';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto'; import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { UserResponseDto } from '../user/response-dto/user-response.dto';
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto'; import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
import { OAuthConfigDto } from './dto/oauth-config.dto'; import { OAuthConfigDto } from './dto/oauth-config.dto';
import { OAuthService } from './oauth.service'; import { OAuthService } from './oauth.service';
@ -22,12 +24,26 @@ export class OAuthController {
@Post('/callback') @Post('/callback')
public async callback( public async callback(
@GetAuthUser() authUser: AuthUserDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
@Body(ValidationPipe) dto: OAuthCallbackDto, @Body(ValidationPipe) dto: OAuthCallbackDto,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const loginResponse = await this.oauthService.callback(authUser, dto); const loginResponse = await this.oauthService.login(dto);
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH)); response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
return loginResponse; return loginResponse;
} }
@Authenticated()
@Post('link')
public async link(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: OAuthCallbackDto,
): Promise<UserResponseDto> {
return this.oauthService.link(authUser, dto);
}
@Authenticated()
@Post('unlink')
public async unlink(@GetAuthUser() authUser: AuthUserDto): Promise<UserResponseDto> {
return this.oauthService.unlink(authUser);
}
} }

View file

@ -1,7 +1,7 @@
import { SystemConfig } from '@app/database/entities/system-config.entity'; import { SystemConfig } from '@app/database/entities/system-config.entity';
import { UserEntity } from '@app/database/entities/user.entity'; import { UserEntity } from '@app/database/entities/user.entity';
import { ImmichConfigService } from '@app/immich-config'; import { ImmichConfigService } from '@app/immich-config';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException, Logger } from '@nestjs/common';
import { generators, Issuer } from 'openid-client'; import { generators, Issuer } from 'openid-client';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
@ -32,6 +32,21 @@ const loginResponse = {
userEmail: 'user@immich.com,', userEmail: 'user@immich.com,',
} as LoginResponseDto; } as LoginResponseDto;
jest.mock('@nestjs/common', () => {
return {
...jest.requireActual('@nestjs/common'),
Logger: function MockLogger() {
Object.assign(this as Logger, {
verbose: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
});
},
};
});
describe('OAuthService', () => { describe('OAuthService', () => {
let sut: OAuthService; let sut: OAuthService;
let userRepositoryMock: jest.Mocked<IUserRepository>; let userRepositoryMock: jest.Mocked<IUserRepository>;
@ -109,9 +124,9 @@ describe('OAuthService', () => {
}); });
}); });
describe('callback', () => { describe('login', () => {
it('should throw an error if OAuth is not enabled', async () => { it('should throw an error if OAuth is not enabled', async () => {
await expect(sut.callback(authUser, { url: '' })).rejects.toBeInstanceOf(BadRequestException); await expect(sut.login({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
}); });
it('should not allow auto registering', async () => { it('should not allow auto registering', async () => {
@ -122,10 +137,8 @@ describe('OAuthService', () => {
}, },
} as SystemConfig); } as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(null); userRepositoryMock.getByEmail.mockResolvedValue(null);
await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf( await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1); expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
@ -139,15 +152,11 @@ describe('OAuthService', () => {
}, },
} as SystemConfig); } as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(user); userRepositoryMock.getByEmail.mockResolvedValue(user);
userRepositoryMock.update.mockResolvedValue(user); userRepositoryMock.update.mockResolvedValue(user);
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse); immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual( await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
loginResponse,
);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1); expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub }); expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
@ -161,16 +170,12 @@ describe('OAuthService', () => {
}, },
} as SystemConfig); } as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock); sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(null); userRepositoryMock.getByEmail.mockResolvedValue(null);
userRepositoryMock.getAdmin.mockResolvedValue(user); userRepositoryMock.getAdmin.mockResolvedValue(user);
userRepositoryMock.create.mockResolvedValue(user); userRepositoryMock.create.mockResolvedValue(user);
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse); immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual( await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
loginResponse,
);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(userRepositoryMock.create).toHaveBeenCalledTimes(1); expect(userRepositoryMock.create).toHaveBeenCalledTimes(1);
@ -178,6 +183,57 @@ describe('OAuthService', () => {
}); });
}); });
describe('link', () => {
it('should link an account', async () => {
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: true,
},
} as SystemConfig);
userRepositoryMock.update.mockResolvedValue(user);
await sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' });
expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: sub });
});
it('should not link an already linked oauth.sub', async () => {
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: true,
},
} as SystemConfig);
userRepositoryMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authUser, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userRepositoryMock.update).not.toHaveBeenCalled();
});
});
describe('unlink', () => {
it('should unlink an account', async () => {
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: true,
},
} as SystemConfig);
userRepositoryMock.update.mockResolvedValue(user);
await sut.unlink(authUser);
expect(userRepositoryMock.update).toHaveBeenCalledWith(authUser.id, { oauthId: '' });
});
});
describe('getLogoutEndpoint', () => { describe('getLogoutEndpoint', () => {
it('should return null if OAuth is not configured', async () => { it('should return null if OAuth is not configured', async () => {
await expect(sut.getLogoutEndpoint()).resolves.toBeNull(); await expect(sut.getLogoutEndpoint()).resolves.toBeNull();

View file

@ -4,6 +4,7 @@ import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'op
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto'; import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { UserResponseDto } from '../user/response-dto/user-response.dto';
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository'; import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
import { UserCore } from '../user/user.core'; import { UserCore } from '../user/user.core';
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto'; import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
@ -47,12 +48,8 @@ export class OAuthService {
return { enabled: true, buttonText, url }; return { enabled: true, buttonText, url };
} }
public async callback(authUser: AuthUserDto, dto: OAuthCallbackDto): Promise<LoginResponseDto> { public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
const redirectUri = dto.url.split('?')[0]; const profile = await this.callback(dto.url);
const client = await this.getClient();
const params = client.callbackParams(dto.url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userCore.getByOAuthId(profile.sub); let user = await this.userCore.getByOAuthId(profile.sub);
@ -61,7 +58,7 @@ export class OAuthService {
if (!user) { if (!user) {
const emailUser = await this.userCore.getByEmail(profile.email); const emailUser = await this.userCore.getByEmail(profile.email);
if (emailUser) { if (emailUser) {
user = await this.userCore.updateUser(authUser, emailUser, { oauthId: profile.sub }); user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
} }
} }
@ -88,6 +85,20 @@ export class OAuthService {
return this.immichJwtService.createLoginResponse(user); return this.immichJwtService.createLoginResponse(user);
} }
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
const { sub: oauthId } = await this.callback(dto.url);
const duplicate = await this.userCore.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== 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 this.userCore.updateUser(user, user.id, { oauthId });
}
public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
return this.userCore.updateUser(user, user.id, { oauthId: '' });
}
public async getLogoutEndpoint(): Promise<string | null> { public async getLogoutEndpoint(): Promise<string | null> {
const config = await this.immichConfigService.getConfig(); const config = await this.immichConfigService.getConfig();
const { enabled } = config.oauth; const { enabled } = config.oauth;
@ -98,6 +109,14 @@ export class OAuthService {
return (await this.getClient()).issuer.metadata.end_session_endpoint || null; return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
} }
private async callback(url: string): Promise<any> {
const redirectUri = url.split('?')[0];
const client = await this.getClient();
const params = client.callbackParams(url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
}
private async getClient() { private async getClient() {
const config = await this.immichConfigService.getConfig(); const config = await this.immichConfigService.getConfig();
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth; const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;

View file

@ -10,6 +10,7 @@ export class UserResponseDto {
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
isAdmin!: boolean; isAdmin!: boolean;
deletedAt?: Date; deletedAt?: Date;
oauthId!: string;
} }
export function mapUser(entity: UserEntity): UserResponseDto { export function mapUser(entity: UserEntity): UserResponseDto {
@ -23,5 +24,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
shouldChangePassword: entity.shouldChangePassword, shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin, isAdmin: entity.isAdmin,
deletedAt: entity.deletedAt, deletedAt: entity.deletedAt,
oauthId: entity.oauthId,
}; };
} }

View file

@ -25,27 +25,22 @@ export class UserCore {
return hash(password, salt); return hash(password, salt);
} }
async updateUser(authUser: AuthUserDto, userToUpdate: UserEntity, data: Partial<UserEntity>): Promise<UserEntity> { async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
if (!authUser.isAdmin && (authUser.id !== userToUpdate.id || userToUpdate.id != data.id)) { if (!(authUser.isAdmin || authUser.id === id)) {
throw new ForbiddenException('You are not allowed to update this user'); throw new ForbiddenException('You are not allowed to update this user');
} }
// TODO: can this happen? If so we should implement a test case, otherwise remove it (also from DTO) if (dto.isAdmin && authUser.isAdmin && authUser.id !== id) {
if (userToUpdate.isAdmin) { throw new BadRequestException('Admin user exists');
const adminUser = await this.userRepository.getAdmin();
if (adminUser && adminUser.id !== userToUpdate.id) {
throw new BadRequestException('Admin user exists');
}
} }
try { try {
const payload: Partial<UserEntity> = { ...data }; if (dto.password) {
if (payload.password) {
const salt = await this.generateSalt(); const salt = await this.generateSalt();
payload.salt = salt; dto.salt = salt;
payload.password = await this.hashPassword(payload.password, salt); dto.password = await this.hashPassword(dto.password, salt);
} }
return this.userRepository.update(userToUpdate.id, payload); return this.userRepository.update(id, dto);
} catch (e) { } catch (e) {
Logger.error(e, 'Failed to update user info'); Logger.error(e, 'Failed to update user info');
throw new InternalServerErrorException('Failed to update user info'); throw new InternalServerErrorException('Failed to update user info');

View file

@ -141,6 +141,25 @@ describe('UserService', () => {
}); });
describe('Create user', () => { describe('Create user', () => {
it('should let the admin update himself', async () => {
const dto = { id: adminUser.id, shouldChangePassword: true, isAdmin: true };
when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValueOnce(null);
when(userRepositoryMock.update).calledWith(adminUser.id, dto).mockResolvedValueOnce(adminUser);
await sut.updateUser(adminUser, dto);
expect(userRepositoryMock.update).toHaveBeenCalledWith(adminUser.id, dto);
});
it('should not let the another user become an admin', async () => {
const dto = { id: immichUser.id, shouldChangePassword: true, isAdmin: true };
when(userRepositoryMock.get).calledWith(immichUser.id).mockResolvedValueOnce(immichUser);
await expect(sut.updateUser(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException);
});
it('should not create a user if there is no local admin account', async () => { it('should not create a user if there is no local admin account', async () => {
when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null); when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null);

View file

@ -65,12 +65,12 @@ export class UserService {
return mapUser(createdUser); return mapUser(createdUser);
} }
async updateUser(authUser: AuthUserDto, updateUserDto: UpdateUserDto): Promise<UserResponseDto> { async updateUser(authUser: AuthUserDto, dto: UpdateUserDto): Promise<UserResponseDto> {
const user = await this.userCore.get(updateUserDto.id); const user = await this.userCore.get(dto.id);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }
const updatedUser = await this.userCore.updateUser(authUser, user, updateUserDto); const updatedUser = await this.userCore.updateUser(authUser, dto.id, dto);
return mapUser(updatedUser); return mapUser(updatedUser);
} }

View file

@ -107,6 +107,7 @@ describe('User', () => {
shouldChangePassword: true, shouldChangePassword: true,
profileImagePath: '', profileImagePath: '',
deletedAt: null, deletedAt: null,
oauthId: '',
}, },
{ {
email: userTwoEmail, email: userTwoEmail,
@ -118,6 +119,7 @@ describe('User', () => {
shouldChangePassword: true, shouldChangePassword: true,
profileImagePath: '', profileImagePath: '',
deletedAt: null, deletedAt: null,
oauthId: '',
}, },
]), ]),
); );

View file

@ -1826,6 +1826,58 @@
] ]
} }
}, },
"/oauth/link": {
"post": {
"operationId": "link",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthCallbackDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
}
}
}
}
},
"tags": [
"OAuth"
]
}
},
"/oauth/unlink": {
"post": {
"operationId": "unlink",
"parameters": [],
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
}
}
}
}
},
"tags": [
"OAuth"
]
}
},
"/device-info": { "/device-info": {
"post": { "post": {
"operationId": "createDeviceInfo", "operationId": "createDeviceInfo",
@ -2287,6 +2339,9 @@
"deletedAt": { "deletedAt": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
},
"oauthId": {
"type": "string"
} }
}, },
"required": [ "required": [
@ -2297,7 +2352,8 @@
"createdAt", "createdAt",
"profileImagePath", "profileImagePath",
"shouldChangePassword", "shouldChangePassword",
"isAdmin" "isAdmin",
"oauthId"
] ]
}, },
"CreateUserDto": { "CreateUserDto": {

View file

@ -24,7 +24,7 @@ export class UserEntity {
@Column({ default: '', select: false }) @Column({ default: '', select: false })
salt?: string; salt?: string;
@Column({ default: '', select: false }) @Column({ default: '' })
oauthId!: string; oauthId!: string;
@Column({ default: '' }) @Column({ default: '' })

View file

@ -1951,6 +1951,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto * @memberof UserResponseDto
*/ */
'deletedAt'?: string; 'deletedAt'?: string;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'oauthId': string;
} }
/** /**
* *
@ -5059,6 +5065,70 @@ export const OAuthApiAxiosParamCreator = function (configuration?: Configuration
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration) localVarRequestOptions.data = serializeDataIfNeeded(oAuthConfigDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {OAuthCallbackDto} oAuthCallbackDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
link: async (oAuthCallbackDto: OAuthCallbackDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'oAuthCallbackDto' is not null or undefined
assertParamExists('link', 'oAuthCallbackDto', oAuthCallbackDto)
const localVarPath = `/oauth/link`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(oAuthCallbackDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
unlink: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/oauth/unlink`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return { return {
url: toPathString(localVarUrlObj), url: toPathString(localVarUrlObj),
options: localVarRequestOptions, options: localVarRequestOptions,
@ -5094,6 +5164,25 @@ export const OAuthApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {OAuthCallbackDto} oAuthCallbackDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async link(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.link(oAuthCallbackDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async unlink(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.unlink(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
} }
}; };
@ -5122,6 +5211,23 @@ export const OAuthApiFactory = function (configuration?: Configuration, basePath
generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> { generateConfig(oAuthConfigDto: OAuthConfigDto, options?: any): AxiosPromise<OAuthConfigResponseDto> {
return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath)); return localVarFp.generateConfig(oAuthConfigDto, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {OAuthCallbackDto} oAuthCallbackDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
link(oAuthCallbackDto: OAuthCallbackDto, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.link(oAuthCallbackDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
unlink(options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.unlink(options).then((request) => request(axios, basePath));
},
}; };
}; };
@ -5153,6 +5259,27 @@ export class OAuthApi extends BaseAPI {
public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) { public generateConfig(oAuthConfigDto: OAuthConfigDto, options?: AxiosRequestConfig) {
return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath)); return OAuthApiFp(this.configuration).generateConfig(oAuthConfigDto, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {OAuthCallbackDto} oAuthCallbackDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof OAuthApi
*/
public link(oAuthCallbackDto: OAuthCallbackDto, options?: AxiosRequestConfig) {
return OAuthApiFp(this.configuration).link(oAuthCallbackDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof OAuthApi
*/
public unlink(options?: AxiosRequestConfig) {
return OAuthApiFp(this.configuration).unlink(options).then((request) => request(this.axios, this.basePath));
}
} }

View file

@ -1,3 +1,7 @@
import { AxiosError, AxiosPromise } from 'axios';
import { api } from './api';
import { UserResponseDto } from './open-api';
const _basePath = '/api'; const _basePath = '/api';
export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) { export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) {
@ -9,3 +13,26 @@ export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean)
return urlObj.href; return urlObj.href;
} }
export type ApiError = AxiosError<{ message: string }>;
export const oauth = {
isCallback: (location: Location) => {
const search = location.search;
return search.includes('code=') || search.includes('error=');
},
getConfig: (location: Location) => {
const redirectUri = location.href.split('?')[0];
console.log(`OAuth Redirect URI: ${redirectUri}`);
return api.oauthApi.generateConfig({ redirectUri });
},
login: (location: Location) => {
return api.oauthApi.callback({ url: location.href });
},
link: (location: Location): AxiosPromise<UserResponseDto> => {
return api.oauthApi.link({ url: location.href });
},
unlink: () => {
return api.oauthApi.unlink();
}
};

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { loginPageMessage } from '$lib/constants'; import { loginPageMessage } from '$lib/constants';
import { api, OAuthConfigResponseDto } from '@api'; import { api, oauth, OAuthConfigResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
let error: string; let error: string;
@ -14,11 +14,10 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
onMount(async () => { onMount(async () => {
const search = window.location.search; if (oauth.isCallback(window.location)) {
if (search.includes('code=') || search.includes('error=')) {
try { try {
loading = true; loading = true;
await api.oauthApi.callback({ url: window.location.href }); await oauth.login(window.location);
dispatch('success'); dispatch('success');
return; return;
} catch (e) { } catch (e) {
@ -29,9 +28,7 @@
} }
try { try {
const redirectUri = window.location.href.split('?')[0]; const { data } = await oauth.getConfig(window.location);
console.log(`OAuth Redirect URI: ${redirectUri}`);
const { data } = await api.oauthApi.generateConfig({ redirectUri });
oauthConfig = data; oauthConfig = data;
} catch (e) { } catch (e) {
console.error('Error [login-form] [oauth.generateConfig]', e); console.error('Error [login-form] [oauth.generateConfig]', e);

View file

@ -0,0 +1,78 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, ApiError } from '@api';
import { fade } from 'svelte/transition';
import SettingInputField, {
SettingInputFieldType
} from '../admin-page/settings/setting-input-field.svelte';
let password = '';
let newPassword = '';
let confirmPassword = '';
const handleChangePassword = async () => {
try {
await api.authenticationApi.changePassword({
password,
newPassword
});
notificationController.show({
message: 'Updated password',
type: NotificationType.Info
});
password = '';
newPassword = '';
confirmPassword = '';
} catch (error) {
console.error('Error [user-profile] [changePassword]', error);
notificationController.show({
message: (error as ApiError)?.response?.data?.message || 'Unable to change password',
type: NotificationType.Error
});
}
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="Password"
bind:value={password}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="New password"
bind:value={newPassword}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="Confirm password"
bind:value={confirmPassword}
required={true}
/>
<div class="flex justify-end">
<button
type="submit"
disabled={!(password && newPassword && newPassword === confirmPassword)}
on:click={() => handleChangePassword()}
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Save
</button>
</div>
</div>
</form>
</div>
</section>

View file

@ -0,0 +1,86 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { oauth, OAuthConfigResponseDto, UserResponseDto } from '@api';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
export let user: UserResponseDto;
let config: OAuthConfigResponseDto = { enabled: false };
let loading = true;
onMount(async () => {
if (oauth.isCallback(window.location)) {
try {
loading = true;
const { data } = await oauth.link(window.location);
user = data;
notificationController.show({
message: 'Linked OAuth account',
type: NotificationType.Info
});
} catch (error) {
handleError(error, 'Unable to link OAuth account');
} finally {
goto('?open=oauth');
}
}
try {
const { data } = await oauth.getConfig(window.location);
config = data;
} catch (error) {
handleError(error, 'Unable to load OAuth config');
}
loading = false;
});
const handleUnlink = async () => {
try {
const { data } = await oauth.unlink();
user = data;
notificationController.show({
message: 'Unlinked OAuth account',
type: NotificationType.Info
});
} catch (error) {
handleError(error, 'Unable to unlink account');
}
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="flex justify-end">
{#if loading}
<div class="flex place-items-center place-content-center">
<LoadingSpinner />
</div>
{:else if config.enabled}
{#if user.oauthId}
<button
on:click={() => handleUnlink()}
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Unlink OAuth
</button>
{:else}
<a href={config.url}>
<button
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Link to OAuth</button
>
</a>
{/if}
{/if}
</div>
</div>
</section>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, UserResponseDto } from '@api';
import { fade } from 'svelte/transition';
import SettingInputField, {
SettingInputFieldType
} from '../admin-page/settings/setting-input-field.svelte';
export let user: UserResponseDto;
const handleSaveProfile = async () => {
try {
const { data } = await api.userApi.updateUser({
id: user.id,
firstName: user.firstName,
lastName: user.lastName
});
Object.assign(user, data);
notificationController.show({
message: 'Saved profile',
type: NotificationType.Info
});
} catch (error) {
console.error('Error [user-profile] [updateProfile]', error);
notificationController.show({
message: 'Unable to save profile',
type: NotificationType.Error
});
}
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="User ID"
bind:value={user.id}
disabled={true}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Email"
bind:value={user.email}
disabled={true}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="First name"
bind:value={user.firstName}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Last name"
bind:value={user.lastName}
required={true}
/>
<div class="flex justify-end">
<button
type="submit"
on:click={() => handleSaveProfile()}
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Save
</button>
</div>
</div>
</form>
</div>
</section>

View file

@ -1,156 +1,43 @@
<script lang="ts"> <script lang="ts">
import { import { page } from '$app/stores';
notificationController, import { oauth, UserResponseDto } from '@api';
NotificationType import { onMount } from 'svelte';
} from '$lib/components/shared-components/notification/notification';
import { api, UserResponseDto } from '@api';
import { AxiosError } from 'axios';
import { fade } from 'svelte/transition';
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte'; import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
import SettingInputField, { import ChangePasswordSettings from './change-password-settings.svelte';
SettingInputFieldType import OAuthSettings from './oauth-settings.svelte';
} from '../admin-page/settings/setting-input-field.svelte'; import UserProfileSettings from './user-profile-settings.svelte';
type ApiError = AxiosError<{ message: string }>;
export let user: UserResponseDto; export let user: UserResponseDto;
const handleSaveProfile = async () => { let oauthEnabled = false;
let oauthOpen = false;
onMount(async () => {
oauthOpen = oauth.isCallback(window.location);
try { try {
const { data } = await api.userApi.updateUser({ const { data } = await oauth.getConfig(window.location);
id: user.id, oauthEnabled = data.enabled;
firstName: user.firstName, } catch {
lastName: user.lastName // noop
});
Object.assign(user, data);
notificationController.show({
message: 'Saved profile',
type: NotificationType.Info
});
} catch (error) {
console.error('Error [user-profile] [updateProfile]', error);
notificationController.show({
message: 'Unable to save profile',
type: NotificationType.Error
});
} }
}; });
let password = '';
let newPassword = '';
let confirmPassword = '';
const handleChangePassword = async () => {
try {
await api.authenticationApi.changePassword({
password,
newPassword
});
notificationController.show({
message: 'Updated password',
type: NotificationType.Info
});
password = '';
newPassword = '';
confirmPassword = '';
} catch (error) {
console.error('Error [user-profile] [changePassword]', error);
notificationController.show({
message: (error as ApiError)?.response?.data?.message || 'Unable to change password',
type: NotificationType.Error
});
}
};
</script> </script>
<SettingAccordion title="User Profile" subtitle="View and manage your profile"> <SettingAccordion title="User Profile" subtitle="View and manage your profile">
<section class="my-4"> <UserProfileSettings {user} />
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="User ID"
bind:value={user.id}
disabled={true}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Email"
bind:value={user.email}
disabled={true}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="First name"
bind:value={user.firstName}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Last name"
bind:value={user.lastName}
required={true}
/>
<div class="flex justify-end">
<button
type="submit"
on:click={() => handleSaveProfile()}
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Save
</button>
</div>
</div>
</form>
</div>
</section>
</SettingAccordion> </SettingAccordion>
<SettingAccordion title="Password" subtitle="Change your password"> <SettingAccordion title="Password" subtitle="Change your password">
<section class="my-4"> <ChangePasswordSettings />
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="Password"
bind:value={password}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="New password"
bind:value={newPassword}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="Confirm password"
bind:value={confirmPassword}
required={true}
/>
<div class="flex justify-end">
<button
type="submit"
disabled={!(password && newPassword && newPassword === confirmPassword)}
on:click={() => handleChangePassword()}
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Save
</button>
</div>
</div>
</form>
</div>
</section>
</SettingAccordion> </SettingAccordion>
{#if oauthEnabled}
<SettingAccordion
title="OAuth"
subtitle="Manage your linked account"
isOpen={oauthOpen || $page.url.searchParams.get('open') === 'oauth'}
>
<OAuthSettings {user} />
</SettingAccordion>
{/if}

View file

@ -0,0 +1,13 @@
import { ApiError } from '../../api';
import {
notificationController,
NotificationType
} from '../components/shared-components/notification/notification';
export function handleError(error: unknown, message: string) {
console.error(`[handleError]: ${message}`, error);
notificationController.show({
message: (error as ApiError)?.response?.data?.message || message,
type: NotificationType.Error
});
}