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 877 additions and 205 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:
- 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`)
- Mobile app redirect URL `app.immich:/`
- `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
- `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
You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly.
:::info Redirect URIs
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
@ -42,7 +61,7 @@ Once you have a new OAuth client application configured, Immich can be configure
| 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) |
| Client ID | string | (required) | Required. Client ID (from previous step) |
| Client secret | string | (required) | Required. Client Secret (previous step) |

View File

@ -108,6 +108,8 @@ Class | Method | HTTP request | Description
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
*OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback |
*OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config |
*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |
*OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink |
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |

View File

@ -11,6 +11,8 @@ Method | HTTP request | Description
------------- | ------------- | -------------
[**callback**](OAuthApi.md#callback) | **POST** /oauth/callback |
[**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config |
[**link**](OAuthApi.md#link) | **POST** /oauth/link |
[**unlink**](OAuthApi.md#unlink) | **POST** /oauth/unlink |
# **callback**
@ -95,3 +97,81 @@ No authorization required
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **link**
> UserResponseDto link(oAuthCallbackDto)
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = OAuthApi();
final oAuthCallbackDto = OAuthCallbackDto(); // OAuthCallbackDto |
try {
final result = api_instance.link(oAuthCallbackDto);
print(result);
} catch (e) {
print('Exception when calling OAuthApi->link: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**oAuthCallbackDto** | [**OAuthCallbackDto**](OAuthCallbackDto.md)| |
### Return type
[**UserResponseDto**](UserResponseDto.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **unlink**
> UserResponseDto unlink()
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = OAuthApi();
try {
final result = api_instance.unlink();
print(result);
} catch (e) {
print('Exception when calling OAuthApi->unlink: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**UserResponseDto**](UserResponseDto.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@ -17,6 +17,7 @@ Name | Type | Description | Notes
**shouldChangePassword** | **bool** | |
**isAdmin** | **bool** | |
**deletedAt** | [**DateTime**](DateTime.md) | | [optional]
**oauthId** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -109,4 +109,92 @@ class OAuthApi {
}
return null;
}
/// Performs an HTTP 'POST /oauth/link' operation and returns the [Response].
/// Parameters:
///
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
Future<Response> linkWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async {
// ignore: prefer_const_declarations
final path = r'/oauth/link';
// ignore: prefer_final_locals
Object? postBody = oAuthCallbackDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
Future<UserResponseDto?> link(OAuthCallbackDto oAuthCallbackDto,) async {
final response = await linkWithHttpInfo(oAuthCallbackDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /oauth/unlink' operation and returns the [Response].
Future<Response> unlinkWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/oauth/unlink';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<UserResponseDto?> unlink() async {
final response = await unlinkWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
}
return null;
}
}

View File

@ -22,6 +22,7 @@ class UserResponseDto {
required this.shouldChangePassword,
required this.isAdmin,
this.deletedAt,
required this.oauthId,
});
String id;
@ -48,6 +49,8 @@ class UserResponseDto {
///
DateTime? deletedAt;
String oauthId;
@override
bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
other.id == id &&
@ -58,7 +61,8 @@ class UserResponseDto {
other.profileImagePath == profileImagePath &&
other.shouldChangePassword == shouldChangePassword &&
other.isAdmin == isAdmin &&
other.deletedAt == deletedAt;
other.deletedAt == deletedAt &&
other.oauthId == oauthId;
@override
int get hashCode =>
@ -71,10 +75,11 @@ class UserResponseDto {
(profileImagePath.hashCode) +
(shouldChangePassword.hashCode) +
(isAdmin.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode);
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(oauthId.hashCode);
@override
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, oauthId=$oauthId]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
@ -91,6 +96,7 @@ class UserResponseDto {
} else {
_json[r'deletedAt'] = null;
}
_json[r'oauthId'] = oauthId;
return _json;
}
@ -122,6 +128,7 @@ class UserResponseDto {
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
deletedAt: mapDateTime(json, r'deletedAt', ''),
oauthId: mapValueOfType<String>(json, r'oauthId')!,
);
}
return null;
@ -179,6 +186,7 @@ class UserResponseDto {
'profileImagePath',
'shouldChangePassword',
'isAdmin',
'oauthId',
};
}

View File

@ -27,5 +27,15 @@ void main() {
// TODO
});
//Future<UserResponseDto> link(OAuthCallbackDto oAuthCallbackDto) async
test('test link', () async {
// TODO
});
//Future<UserResponseDto> unlink() async
test('test unlink', () async {
// TODO
});
});
}

View File

@ -61,6 +61,11 @@ void main() {
// TODO
});
// String oauthId
test('to test the property `oauthId`', () async {
// TODO
});
});

View File

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

View File

@ -3,8 +3,10 @@ import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { AuthType } from '../../constants/jwt.constant';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
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 { OAuthConfigDto } from './dto/oauth-config.dto';
import { OAuthService } from './oauth.service';
@ -22,12 +24,26 @@ export class OAuthController {
@Post('/callback')
public async callback(
@GetAuthUser() authUser: AuthUserDto,
@Res({ passthrough: true }) response: Response,
@Body(ValidationPipe) dto: OAuthCallbackDto,
): 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));
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 { UserEntity } from '@app/database/entities/user.entity';
import { ImmichConfigService } from '@app/immich-config';
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, Logger } from '@nestjs/common';
import { generators, Issuer } from 'openid-client';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
@ -32,6 +32,21 @@ const loginResponse = {
userEmail: 'user@immich.com,',
} 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', () => {
let sut: OAuthService;
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 () => {
await expect(sut.callback(authUser, { url: '' })).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.login({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
@ -122,10 +137,8 @@ describe('OAuthService', () => {
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => 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,
);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
@ -139,15 +152,11 @@ describe('OAuthService', () => {
},
} as SystemConfig);
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.update.mockResolvedValue(user);
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
loginResponse,
);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
expect(userRepositoryMock.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
@ -161,16 +170,12 @@ describe('OAuthService', () => {
},
} as SystemConfig);
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.getAdmin.mockResolvedValue(user);
userRepositoryMock.create.mockResolvedValue(user);
immichJwtServiceMock.createLoginResponse.mockResolvedValue(loginResponse);
await expect(sut.callback(authUser, { url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(
loginResponse,
);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' })).resolves.toEqual(loginResponse);
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
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', () => {
it('should return null if OAuth is not configured', async () => {
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 { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
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 { UserCore } from '../user/user.core';
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
@ -47,12 +48,8 @@ export class OAuthService {
return { enabled: true, buttonText, url };
}
public async callback(authUser: AuthUserDto, dto: OAuthCallbackDto): Promise<LoginResponseDto> {
const redirectUri = dto.url.split('?')[0];
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 || '');
public async login(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
const profile = await this.callback(dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userCore.getByOAuthId(profile.sub);
@ -61,7 +58,7 @@ export class OAuthService {
if (!user) {
const emailUser = await this.userCore.getByEmail(profile.email);
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);
}
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> {
const config = await this.immichConfigService.getConfig();
const { enabled } = config.oauth;
@ -98,6 +109,14 @@ export class OAuthService {
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() {
const config = await this.immichConfigService.getConfig();
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;

View File

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

View File

@ -25,27 +25,22 @@ export class UserCore {
return hash(password, salt);
}
async updateUser(authUser: AuthUserDto, userToUpdate: UserEntity, data: Partial<UserEntity>): Promise<UserEntity> {
if (!authUser.isAdmin && (authUser.id !== userToUpdate.id || userToUpdate.id != data.id)) {
async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
if (!(authUser.isAdmin || authUser.id === id)) {
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 (userToUpdate.isAdmin) {
const adminUser = await this.userRepository.getAdmin();
if (adminUser && adminUser.id !== userToUpdate.id) {
throw new BadRequestException('Admin user exists');
}
if (dto.isAdmin && authUser.isAdmin && authUser.id !== id) {
throw new BadRequestException('Admin user exists');
}
try {
const payload: Partial<UserEntity> = { ...data };
if (payload.password) {
if (dto.password) {
const salt = await this.generateSalt();
payload.salt = salt;
payload.password = await this.hashPassword(payload.password, salt);
dto.salt = salt;
dto.password = await this.hashPassword(dto.password, salt);
}
return this.userRepository.update(userToUpdate.id, payload);
return this.userRepository.update(id, dto);
} catch (e) {
Logger.error(e, 'Failed to update user info');
throw new InternalServerErrorException('Failed to update user info');

View File

@ -141,6 +141,25 @@ describe('UserService', () => {
});
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 () => {
when(userRepositoryMock.getAdmin).calledWith().mockResolvedValueOnce(null);

View File

@ -65,12 +65,12 @@ export class UserService {
return mapUser(createdUser);
}
async updateUser(authUser: AuthUserDto, updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
const user = await this.userCore.get(updateUserDto.id);
async updateUser(authUser: AuthUserDto, dto: UpdateUserDto): Promise<UserResponseDto> {
const user = await this.userCore.get(dto.id);
if (!user) {
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);
}

View File

@ -107,6 +107,7 @@ describe('User', () => {
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
oauthId: '',
},
{
email: userTwoEmail,
@ -118,6 +119,7 @@ describe('User', () => {
shouldChangePassword: true,
profileImagePath: '',
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": {
"post": {
"operationId": "createDeviceInfo",
@ -2287,6 +2339,9 @@
"deletedAt": {
"format": "date-time",
"type": "string"
},
"oauthId": {
"type": "string"
}
},
"required": [
@ -2297,7 +2352,8 @@
"createdAt",
"profileImagePath",
"shouldChangePassword",
"isAdmin"
"isAdmin",
"oauthId"
]
},
"CreateUserDto": {

View File

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

View File

@ -1951,6 +1951,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'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.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 {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@ -5094,6 +5164,25 @@ export const OAuthApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.generateConfig(oAuthConfigDto, options);
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> {
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) {
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';
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;
}
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">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { loginPageMessage } from '$lib/constants';
import { api, OAuthConfigResponseDto } from '@api';
import { api, oauth, OAuthConfigResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
let error: string;
@ -14,11 +14,10 @@
const dispatch = createEventDispatcher();
onMount(async () => {
const search = window.location.search;
if (search.includes('code=') || search.includes('error=')) {
if (oauth.isCallback(window.location)) {
try {
loading = true;
await api.oauthApi.callback({ url: window.location.href });
await oauth.login(window.location);
dispatch('success');
return;
} catch (e) {
@ -29,9 +28,7 @@
}
try {
const redirectUri = window.location.href.split('?')[0];
console.log(`OAuth Redirect URI: ${redirectUri}`);
const { data } = await api.oauthApi.generateConfig({ redirectUri });
const { data } = await oauth.getConfig(window.location);
oauthConfig = data;
} catch (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">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, UserResponseDto } from '@api';
import { AxiosError } from 'axios';
import { fade } from 'svelte/transition';
import { page } from '$app/stores';
import { oauth, UserResponseDto } from '@api';
import { onMount } from 'svelte';
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
import SettingInputField, {
SettingInputFieldType
} from '../admin-page/settings/setting-input-field.svelte';
type ApiError = AxiosError<{ message: string }>;
import ChangePasswordSettings from './change-password-settings.svelte';
import OAuthSettings from './oauth-settings.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
export let user: UserResponseDto;
const handleSaveProfile = async () => {
let oauthEnabled = false;
let oauthOpen = false;
onMount(async () => {
oauthOpen = oauth.isCallback(window.location);
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
});
const { data } = await oauth.getConfig(window.location);
oauthEnabled = data.enabled;
} catch {
// noop
}
};
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>
<SettingAccordion title="User Profile" subtitle="View and manage your profile">
<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>
<UserProfileSettings {user} />
</SettingAccordion>
<SettingAccordion title="Password" subtitle="Change your password">
<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>
<ChangePasswordSettings />
</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
});
}