mirror of https://github.com/immich-app/immich.git
feat(server,web): OIDC Implementation (#884)
* chore: merge * feat: nullable password * feat: server debugger * chore: regenerate api * feat: auto-register flag * refactor: oauth endpoints * chore: regenerate api * fix: default scope configuration * refactor: pass in redirect uri from client * chore: docs * fix: bugs * refactor: auth services and user repository * fix: select password * fix: tests * fix: get signing algorithm from discovery document * refactor: cookie constants * feat: oauth logout * test: auth services * fix: query param check * fix: regenerate open-apipull/959/head^2
parent
d476656789
commit
d3c35ec9c5
@ -0,0 +1,68 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# OAuth Authentication
|
||||
|
||||
This page contains details about using OAuth 2 in Immich.
|
||||
|
||||
## Overview
|
||||
|
||||
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
||||
|
||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
|
||||
- [Okta](https://www.okta.com/openid-connect/)
|
||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
|
||||
|
||||
1. Create a new (Client) Application
|
||||
|
||||
1. The **Provider** type should be `OpenID Connect` or `OAuth2`
|
||||
2. The **Client type** should be `Confidential`
|
||||
3. The **Application** type should be `Web`
|
||||
4. The **Grant** type should be `Authorization Code`
|
||||
|
||||
2. Configure Redirect URIs/Origins
|
||||
|
||||
1. 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`)
|
||||
|
||||
## Enable OAuth
|
||||
|
||||
Once you have a new OAuth client application configured, Immich can be configured using the following environment variables:
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
|
||||
| OAUTH_ENABLED | boolean | false | Enable/disable OAuth2 |
|
||||
| OAUTH_ISSUER_URL | URL | (required) | Required. Self-discovery URL for client (from previous step) |
|
||||
| OAUTH_CLIENT_ID | string | (required) | Required. Client ID (from previous step) |
|
||||
| OAUTH_CLIENT_SECRET | string | (required) | Required. Client Secret (previous step |
|
||||
| OAUTH_SCOPE | string | openid email profile | Full list of scopes to send with the request (space delimited) |
|
||||
| OAUTH_AUTO_REGISTER | boolean | true | When true, will automatically register a user the first time they sign in |
|
||||
| OAUTH_BUTTON_TEXT | string | Login with OAuth | Text for the OAuth button on the web |
|
||||
|
||||
:::info
|
||||
The Issuer URL should look something like the following, and return a valid json document.
|
||||
|
||||
- `https://accounts.google.com/.well-known/openid-configuration`
|
||||
- `http://localhost:9000/application/o/immich/.well-known/openid-configuration`
|
||||
|
||||
The `.well-known/openid-configuration` part of the url is optional and will be automatically added during discovery.
|
||||
:::
|
||||
|
||||
Here is an example of a valid configuration for setting up Immich to use OAuth with Authentik:
|
||||
|
||||
```
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_ISSUER_URL=http://192.168.0.187:9000/application/o/immich
|
||||
OAUTH_CLIENT_ID=f08f9c5b4f77dcfd3916b1c032336b5544a7b368
|
||||
OAUTH_CLIENT_SECRET=6fe2e697644da6ff6aef73387a457d819018189086fa54b151a6067fbb884e75f7e5c90be16d3c688cf902c6974817a85eab93007d76675041eaead8c39cf5a2
|
||||
OAUTH_BUTTON_TEXT=Login with Authentik
|
||||
```
|
||||
|
||||
[oidc]: https://openid.net/connect/
|
||||
@ -0,0 +1,97 @@
|
||||
# openapi.api.OAuthApi
|
||||
|
||||
## Load the API package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
All URIs are relative to */api*
|
||||
|
||||
Method | HTTP request | Description
|
||||
------------- | ------------- | -------------
|
||||
[**callback**](OAuthApi.md#callback) | **POST** /oauth/callback |
|
||||
[**generateConfig**](OAuthApi.md#generateconfig) | **POST** /oauth/config |
|
||||
|
||||
|
||||
# **callback**
|
||||
> LoginResponseDto callback(oAuthCallbackDto)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final api_instance = OAuthApi();
|
||||
final oAuthCallbackDto = OAuthCallbackDto(); // OAuthCallbackDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.callback(oAuthCallbackDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling OAuthApi->callback: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**oAuthCallbackDto** | [**OAuthCallbackDto**](OAuthCallbackDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**LoginResponseDto**](LoginResponseDto.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)
|
||||
|
||||
# **generateConfig**
|
||||
> OAuthConfigResponseDto generateConfig(oAuthConfigDto)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final api_instance = OAuthApi();
|
||||
final oAuthConfigDto = OAuthConfigDto(); // OAuthConfigDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.generateConfig(oAuthConfigDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling OAuthApi->generateConfig: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**oAuthConfigDto** | [**OAuthConfigDto**](OAuthConfigDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**OAuthConfigResponseDto**](OAuthConfigResponseDto.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)
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
# openapi.model.OAuthCallbackDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**url** | **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)
|
||||
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
# openapi.model.OAuthConfigDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**redirectUri** | **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)
|
||||
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
# openapi.model.OAuthConfigResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**enabled** | **bool** | | [readonly]
|
||||
**url** | **String** | | [optional] [readonly]
|
||||
**buttonText** | **String** | | [optional] [readonly]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,112 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class OAuthApi {
|
||||
OAuthApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Performs an HTTP 'POST /oauth/callback' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
|
||||
Future<Response> callbackWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/oauth/callback';
|
||||
|
||||
// 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<LoginResponseDto?> callback(OAuthCallbackDto oAuthCallbackDto,) async {
|
||||
final response = await callbackWithHttpInfo(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), 'LoginResponseDto',) as LoginResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'POST /oauth/config' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [OAuthConfigDto] oAuthConfigDto (required):
|
||||
Future<Response> generateConfigWithHttpInfo(OAuthConfigDto oAuthConfigDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/oauth/config';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = oAuthConfigDto;
|
||||
|
||||
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:
|
||||
///
|
||||
/// * [OAuthConfigDto] oAuthConfigDto (required):
|
||||
Future<OAuthConfigResponseDto?> generateConfig(OAuthConfigDto oAuthConfigDto,) async {
|
||||
final response = await generateConfigWithHttpInfo(oAuthConfigDto,);
|
||||
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), 'OAuthConfigResponseDto',) as OAuthConfigResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class OAuthCallbackDto {
|
||||
/// Returns a new [OAuthCallbackDto] instance.
|
||||
OAuthCallbackDto({
|
||||
required this.url,
|
||||
});
|
||||
|
||||
String url;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is OAuthCallbackDto &&
|
||||
other.url == url;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(url.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'OAuthCallbackDto[url=$url]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'url'] = url;
|
||||
return _json;
|
||||
}
|
||||
|
||||
/// Returns a new [OAuthCallbackDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static OAuthCallbackDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "OAuthCallbackDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "OAuthCallbackDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
|
||||
return OAuthCallbackDto(
|
||||
url: mapValueOfType<String>(json, r'url')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<OAuthCallbackDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <OAuthCallbackDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = OAuthCallbackDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, OAuthCallbackDto> mapFromJson(dynamic json) {
|
||||
final map = <String, OAuthCallbackDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = OAuthCallbackDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of OAuthCallbackDto-objects as value to a dart map
|
||||
static Map<String, List<OAuthCallbackDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<OAuthCallbackDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = OAuthCallbackDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'url',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class OAuthConfigDto {
|
||||
/// Returns a new [OAuthConfigDto] instance.
|
||||
OAuthConfigDto({
|
||||
required this.redirectUri,
|
||||
});
|
||||
|
||||
String redirectUri;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is OAuthConfigDto &&
|
||||
other.redirectUri == redirectUri;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(redirectUri.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'OAuthConfigDto[redirectUri=$redirectUri]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'redirectUri'] = redirectUri;
|
||||
return _json;
|
||||
}
|
||||
|
||||
/// Returns a new [OAuthConfigDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static OAuthConfigDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "OAuthConfigDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "OAuthConfigDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
|
||||
return OAuthConfigDto(
|
||||
redirectUri: mapValueOfType<String>(json, r'redirectUri')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<OAuthConfigDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <OAuthConfigDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = OAuthConfigDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, OAuthConfigDto> mapFromJson(dynamic json) {
|
||||
final map = <String, OAuthConfigDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = OAuthConfigDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of OAuthConfigDto-objects as value to a dart map
|
||||
static Map<String, List<OAuthConfigDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<OAuthConfigDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = OAuthConfigDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'redirectUri',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,145 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class OAuthConfigResponseDto {
|
||||
/// Returns a new [OAuthConfigResponseDto] instance.
|
||||
OAuthConfigResponseDto({
|
||||
required this.enabled,
|
||||
this.url,
|
||||
this.buttonText,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? url;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? buttonText;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is OAuthConfigResponseDto &&
|
||||
other.enabled == enabled &&
|
||||
other.url == url &&
|
||||
other.buttonText == buttonText;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(url == null ? 0 : url!.hashCode) +
|
||||
(buttonText == null ? 0 : buttonText!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'OAuthConfigResponseDto[enabled=$enabled, url=$url, buttonText=$buttonText]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'enabled'] = enabled;
|
||||
if (url != null) {
|
||||
_json[r'url'] = url;
|
||||
} else {
|
||||
_json[r'url'] = null;
|
||||
}
|
||||
if (buttonText != null) {
|
||||
_json[r'buttonText'] = buttonText;
|
||||
} else {
|
||||
_json[r'buttonText'] = null;
|
||||
}
|
||||
return _json;
|
||||
}
|
||||
|
||||
/// Returns a new [OAuthConfigResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static OAuthConfigResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "OAuthConfigResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "OAuthConfigResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
|
||||
return OAuthConfigResponseDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
url: mapValueOfType<String>(json, r'url'),
|
||||
buttonText: mapValueOfType<String>(json, r'buttonText'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<OAuthConfigResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <OAuthConfigResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = OAuthConfigResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, OAuthConfigResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, OAuthConfigResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = OAuthConfigResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of OAuthConfigResponseDto-objects as value to a dart map
|
||||
static Map<String, List<OAuthConfigResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<OAuthConfigResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = OAuthConfigResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
|
||||
/// tests for OAuthApi
|
||||
void main() {
|
||||
// final instance = OAuthApi();
|
||||
|
||||
group('tests for OAuthApi', () {
|
||||
//Future<LoginResponseDto> callback(OAuthCallbackDto oAuthCallbackDto) async
|
||||
test('test callback', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<OAuthConfigResponseDto> getConfig() async
|
||||
test('test getConfig', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for OAuthCallbackDto
|
||||
void main() {
|
||||
// final instance = OAuthCallbackDto();
|
||||
|
||||
group('test OAuthCallbackDto', () {
|
||||
// String url
|
||||
test('to test the property `url`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for OAuthConfigDto
|
||||
void main() {
|
||||
// final instance = OAuthConfigDto();
|
||||
|
||||
group('test OAuthConfigDto', () {
|
||||
// String redirectUri
|
||||
test('to test the property `redirectUri`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for OAuthConfigResponseDto
|
||||
void main() {
|
||||
// final instance = OAuthConfigResponseDto();
|
||||
|
||||
group('test OAuthConfigResponseDto', () {
|
||||
// bool enabled
|
||||
test('to test the property `enabled`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String url
|
||||
test('to test the property `url`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -1,16 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { jwtConfig } from '../../config/jwt.config';
|
||||
import { OAuthModule } from '../oauth/oauth.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
|
||||
imports: [UserModule, ImmichJwtModule, OAuthModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, ImmichJwtService],
|
||||
providers: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AuthType } from '../../constants/jwt.constant';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { OAuthService } from '../oauth/oauth.service';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignUpDto } from './dto/sign-up.dto';
|
||||
import { LoginResponseDto } from './response-dto/login-response.dto';
|
||||
|
||||
const fixtures = {
|
||||
login: {
|
||||
email: 'test@immich.com',
|
||||
password: 'password',
|
||||
},
|
||||
};
|
||||
|
||||
const CLIENT_IP = '127.0.0.1';
|
||||
|
||||
jest.mock('bcrypt');
|
||||
|
||||
describe('AuthService', () => {
|
||||
let sut: AuthService;
|
||||
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
|
||||
let oauthServiceMock: jest.Mocked<OAuthService>;
|
||||
let compare: jest.Mock;
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mock('bcrypt');
|
||||
compare = bcrypt.compare as jest.Mock;
|
||||
|
||||
userRepositoryMock = {
|
||||
get: jest.fn(),
|
||||
getAdmin: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
};
|
||||
|
||||
immichJwtServiceMock = {
|
||||
getCookieNames: jest.fn(),
|
||||
getCookies: jest.fn(),
|
||||
createLoginResponse: jest.fn(),
|
||||
validateToken: jest.fn(),
|
||||
extractJwtFromHeader: jest.fn(),
|
||||
extractJwtFromCookie: jest.fn(),
|
||||
} as unknown as jest.Mocked<ImmichJwtService>;
|
||||
|
||||
oauthServiceMock = {
|
||||
getLogoutEndpoint: jest.fn(),
|
||||
} as unknown as jest.Mocked<OAuthService>;
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: ImmichJwtService, useValue: immichJwtServiceMock },
|
||||
{ provide: OAuthService, useValue: oauthServiceMock },
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useValue: userRepositoryMock,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
sut = moduleRef.get(AuthService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should check the user exists', async () => {
|
||||
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should check the user has a password', async () => {
|
||||
userRepositoryMock.getByEmail.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
userRepositoryMock.getByEmail.mockResolvedValue({ password: 'password' } as UserEntity);
|
||||
compare.mockResolvedValue(true);
|
||||
const dto = { firstName: 'test', lastName: 'immich' } as LoginResponseDto;
|
||||
immichJwtServiceMock.createLoginResponse.mockResolvedValue(dto);
|
||||
await expect(sut.login(fixtures.login, CLIENT_IP)).resolves.toEqual(dto);
|
||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should return the end session endpoint', async () => {
|
||||
oauthServiceMock.getLogoutEndpoint.mockResolvedValue('end-session-endpoint');
|
||||
await expect(sut.logout(AuthType.OAUTH)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'end-session-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the default redirect', async () => {
|
||||
await expect(sut.logout(AuthType.PASSWORD)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: '/auth/login',
|
||||
});
|
||||
expect(oauthServiceMock.getLogoutEndpoint).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminSignUp', () => {
|
||||
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', firstName: 'immich', lastName: 'admin' };
|
||||
|
||||
it('should only allow one admin', async () => {
|
||||
userRepositoryMock.getAdmin.mockResolvedValue({} as UserEntity);
|
||||
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sign up the admin', async () => {
|
||||
userRepositoryMock.getAdmin.mockResolvedValue(null);
|
||||
userRepositoryMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: 'today' } as UserEntity);
|
||||
await expect(sut.adminSignUp(dto)).resolves.toEqual({
|
||||
id: 'admin',
|
||||
createdAt: 'today',
|
||||
email: 'test@immich.com',
|
||||
firstName: 'immich',
|
||||
lastName: 'admin',
|
||||
});
|
||||
expect(userRepositoryMock.getAdmin).toHaveBeenCalled();
|
||||
expect(userRepositoryMock.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,106 +1,80 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { LoginCredentialDto } from './dto/login-credential.dto';
|
||||
import { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UserEntity } from '../../../../../libs/database/src/entities/user.entity';
|
||||
import { AuthType } from '../../constants/jwt.constant';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { JwtPayloadDto } from './dto/jwt-payload.dto';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
|
||||
import { LoginCredentialDto } from './dto/login-credential.dto';
|
||||
import { SignUpDto } from './dto/sign-up.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto/login-response.dto';
|
||||
import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto';
|
||||
import { LoginResponseDto } from './response-dto/login-response.dto';
|
||||
import { LogoutResponseDto } from './response-dto/logout-response.dto';
|
||||
import { OAuthService } from '../oauth/oauth.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
private oauthService: OAuthService,
|
||||
private immichJwtService: ImmichJwtService,
|
||||
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
private async validateUser(loginCredential: LoginCredentialDto): Promise<UserEntity | null> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
email: loginCredential.email,
|
||||
},
|
||||
select: [
|
||||
'id',
|
||||
'email',
|
||||
'password',
|
||||
'salt',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'isAdmin',
|
||||
'profileImagePath',
|
||||
'shouldChangePassword',
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const isAuthenticated = await this.validatePassword(user.password!, loginCredential.password, user.salt!);
|
||||
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
|
||||
let user = await this.userRepository.getByEmail(loginCredential.email, true);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return user;
|
||||
if (user) {
|
||||
const isAuthenticated = await this.validatePassword(loginCredential.password, user);
|
||||
if (!isAuthenticated) {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async login(loginCredential: LoginCredentialDto, clientIp: string): Promise<LoginResponseDto> {
|
||||
const validatedUser = await this.validateUser(loginCredential);
|
||||
|
||||
if (!validatedUser) {
|
||||
if (!user) {
|
||||
Logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
|
||||
throw new BadRequestException('Incorrect email or password');
|
||||
}
|
||||
|
||||
const payload = new JwtPayloadDto(validatedUser.id, validatedUser.email);
|
||||
const accessToken = await this.immichJwtService.generateToken(payload);
|
||||
|
||||
return mapLoginResponse(validatedUser, accessToken);
|
||||
return this.immichJwtService.createLoginResponse(user);
|
||||
}
|
||||
|
||||
public getCookieWithJwtToken(authLoginInfo: LoginResponseDto) {
|
||||
const maxAge = 7 * 24 * 3600; // 7 days
|
||||
return `immich_access_token=${authLoginInfo.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}`;
|
||||
public async logout(authType: AuthType): Promise<LogoutResponseDto> {
|
||||
if (authType === AuthType.OAUTH) {
|
||||
const url = await this.oauthService.getLogoutEndpoint();
|
||||
if (url) {
|
||||
return { successful: true, redirectUri: url };
|
||||
}
|
||||
}
|
||||
|
||||
return { successful: true, redirectUri: '/auth/login' };
|
||||
}
|
||||
|
||||
// !TODO: refactor this method to use the userService createUser method
|
||||
public async adminSignUp(signUpCredential: SignUpDto): Promise<AdminSignupResponseDto> {
|
||||
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
|
||||
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
|
||||
const adminUser = await this.userRepository.getAdmin();
|
||||
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
}
|
||||
|
||||
const newAdminUser = new UserEntity();
|
||||
newAdminUser.email = signUpCredential.email;
|
||||
newAdminUser.salt = await bcrypt.genSalt();
|
||||
newAdminUser.password = await this.hashPassword(signUpCredential.password, newAdminUser.salt);
|
||||
newAdminUser.firstName = signUpCredential.firstName;
|
||||
newAdminUser.lastName = signUpCredential.lastName;
|
||||
newAdminUser.isAdmin = true;
|
||||
|
||||
try {
|
||||
const savedNewAdminUserUser = await this.userRepository.save(newAdminUser);
|
||||
|
||||
return mapAdminSignupResponse(savedNewAdminUserUser);
|
||||
const admin = await this.userRepository.create({
|
||||
isAdmin: true,
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
password: dto.password,
|
||||
});
|
||||
|
||||
return mapAdminSignupResponse(admin);
|
||||
} catch (e) {
|
||||
Logger.error('e', 'signUp');
|
||||
throw new InternalServerErrorException('Failed to register new admin user');
|
||||
}
|
||||
}
|
||||
|
||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
private async validatePassword(hasedPassword: string, inputPassword: string, salt: string): Promise<boolean> {
|
||||
const hash = await bcrypt.hash(inputPassword, salt);
|
||||
return hash === hasedPassword;
|
||||
private async validatePassword(inputPassword: string, user: UserEntity): Promise<boolean> {
|
||||
if (!user || !user.password) {
|
||||
return false;
|
||||
}
|
||||
return await bcrypt.compare(inputPassword, user.password);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
url!: string;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class OAuthConfigDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
redirectUri!: string;
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Post, Res, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { AuthType } from '../../constants/jwt.constant';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
||||
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
||||
import { OAuthService } from './oauth.service';
|
||||
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
|
||||
|
||||
@ApiTags('OAuth')
|
||||
@Controller('oauth')
|
||||
export class OAuthController {
|
||||
constructor(private readonly immichJwtService: ImmichJwtService, private readonly oauthService: OAuthService) {}
|
||||
|
||||
@Post('/config')
|
||||
public generateConfig(@Body(ValidationPipe) dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
||||
return this.oauthService.generateConfig(dto);
|
||||
}
|
||||
|
||||
@Post('/callback')
|
||||
public async callback(@Res({ passthrough: true }) response: Response, @Body(ValidationPipe) dto: OAuthCallbackDto) {
|
||||
const loginResponse = await this.oauthService.callback(dto);
|
||||
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
|
||||
return loginResponse;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { OAuthController } from './oauth.controller';
|
||||
import { OAuthService } from './oauth.service';
|
||||
|
||||
@Module({
|
||||
imports: [UserModule, ImmichJwtModule],
|
||||
controllers: [OAuthController],
|
||||
providers: [OAuthService],
|
||||
exports: [OAuthService],
|
||||
})
|
||||
export class OAuthModule {}
|
||||
@ -0,0 +1,169 @@
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
||||
import { OAuthService } from '../oauth/oauth.service';
|
||||
import { IUserRepository } from '../user/user-repository';
|
||||
|
||||
interface OAuthConfig {
|
||||
OAUTH_ENABLED: boolean;
|
||||
OAUTH_AUTO_REGISTER: boolean;
|
||||
OAUTH_ISSUER_URL: string;
|
||||
OAUTH_SCOPE: string;
|
||||
OAUTH_BUTTON_TEXT: string;
|
||||
}
|
||||
|
||||
const mockConfig = (config: Partial<OAuthConfig>) => {
|
||||
return (value: keyof OAuthConfig, defaultValue: any) => config[value] ?? defaultValue ?? null;
|
||||
};
|
||||
|
||||
const email = 'user@immich.com';
|
||||
|
||||
const user = {
|
||||
id: 'user',
|
||||
email,
|
||||
firstName: 'user',
|
||||
lastName: 'imimch',
|
||||
} as UserEntity;
|
||||
|
||||
const loginResponse = {
|
||||
accessToken: 'access-token',
|
||||
userId: 'user',
|
||||
userEmail: 'user@immich.com,',
|
||||
} as LoginResponseDto;
|
||||
|
||||
describe('OAuthService', () => {
|
||||
let sut: OAuthService;
|
||||
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||
let configServiceMock: jest.Mocked<ConfigService>;
|
||||
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(generators, 'state').mockReturnValue('state');
|
||||
jest.spyOn(Issuer, 'discover').mockResolvedValue({
|
||||
id_token_signing_alg_values_supported: ['HS256'],
|
||||
Client: jest.fn().mockResolvedValue({
|
||||
issuer: {
|
||||
metadata: {
|
||||
end_session_endpoint: 'http://end-session-endpoint',
|
||||
},
|
||||
},
|
||||
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 }),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
userRepositoryMock = {
|
||||
get: jest.fn(),
|
||||
getAdmin: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
};
|
||||
|
||||
immichJwtServiceMock = {
|
||||
getCookieNames: jest.fn(),
|
||||
getCookies: jest.fn(),
|
||||
createLoginResponse: jest.fn(),
|
||||
validateToken: jest.fn(),
|
||||
extractJwtFromHeader: jest.fn(),
|
||||
extractJwtFromCookie: jest.fn(),
|
||||
} as unknown as jest.Mocked<ImmichJwtService>;
|
||||
|
||||
configServiceMock = {
|
||||
get: jest.fn(),
|
||||
} as unknown as jest.Mocked<ConfigService>;
|
||||
|
||||
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateConfig', () => {
|
||||
it('should work when oauth is not configured', async () => {
|
||||
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
|
||||
expect(configServiceMock.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate the config', async () => {
|
||||
configServiceMock.get.mockImplementation(
|
||||
mockConfig({
|
||||
OAUTH_ENABLED: true,
|
||||
OAUTH_BUTTON_TEXT: 'OAuth',
|
||||
}),
|
||||
);
|
||||
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
|
||||
enabled: true,
|
||||
buttonText: 'OAuth',
|
||||
url: 'http://authorization-url',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback', () => {
|
||||
it('should throw an error if OAuth is not enabled', async () => {
|
||||
await expect(sut.callback({ url: '' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should not allow auto registering', 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(null);
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(userRepositoryMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true }));
|
||||
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
|
||||
jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
|
||||
userRepositoryMock.getByEmail.mockResolvedValue(null);
|
||||
userRepositoryMock.create.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.create).toHaveBeenCalledTimes(1);
|
||||
expect(immichJwtServiceMock.createLoginResponse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogoutEndpoint', () => {
|
||||
it('should return null if OAuth is not configured', async () => {
|
||||
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should get the session endpoint from the discovery document', async () => {
|
||||
configServiceMock.get.mockImplementation(
|
||||
mockConfig({
|
||||
OAUTH_ENABLED: true,
|
||||
OAUTH_ISSUER_URL: 'http://issuer',
|
||||
}),
|
||||
);
|
||||
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
|
||||
|
||||
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,108 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ClientMetadata, generators, Issuer, UserinfoResponse } from 'openid-client';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
|
||||
import { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
||||
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
||||
import { OAuthConfigResponseDto } from './response-dto/oauth-config-response.dto';
|
||||
|
||||
type OAuthProfile = UserinfoResponse & {
|
||||
email: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
private readonly enabled: boolean;
|
||||
private readonly autoRegister: boolean;
|
||||
private readonly buttonText: string;
|
||||
private readonly issuerUrl: string;
|
||||
private readonly clientMetadata: ClientMetadata;
|
||||
private readonly scope: string;
|
||||
|
||||
constructor(
|
||||
private immichJwtService: ImmichJwtService,
|
||||
configService: ConfigService,
|
||||
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
|
||||
) {
|
||||
this.enabled = configService.get('OAUTH_ENABLED', false);
|
||||
this.autoRegister = configService.get('OAUTH_AUTO_REGISTER', true);
|
||||
this.issuerUrl = configService.get<string>('OAUTH_ISSUER_URL', '');
|
||||
this.scope = configService.get<string>('OAUTH_SCOPE', '');
|
||||
this.buttonText = configService.get<string>('OAUTH_BUTTON_TEXT', '');
|
||||
|
||||
this.clientMetadata = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
client_id: configService.get('OAUTH_CLIENT_ID')!,
|
||||
client_secret: configService.get('OAUTH_CLIENT_SECRET'),
|
||||
response_types: ['code'],
|
||||
};
|
||||
}
|
||||
|
||||
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
|
||||
if (!this.enabled) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
const url = (await this.getClient()).authorizationUrl({
|
||||
redirect_uri: dto.redirectUri,
|
||||
scope: this.scope,
|
||||
state: generators.state(),
|
||||
});
|
||||
return { enabled: true, buttonText: this.buttonText, url };
|
||||
}
|
||||
|
||||
public async callback(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 || '');
|
||||
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
let user = await this.userRepository.getByEmail(profile.email);
|
||||
|
||||
if (!user) {
|
||||
if (!this.autoRegister) {
|
||||
this.logger.warn(
|
||||
`Unable to register ${profile.email}. To enable auto registering, set OAUTH_AUTO_REGISTER=true.`,
|
||||
);
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user: ${profile.email}`);
|
||||
user = await this.userRepository.create({
|
||||
firstName: profile.given_name || '',
|
||||
lastName: profile.family_name || '',
|
||||
email: profile.email,
|
||||
});
|
||||
}
|
||||
|
||||
return this.immichJwtService.createLoginResponse(user);
|
||||
}
|
||||
|
||||
public async getLogoutEndpoint(): Promise<string | null> {
|
||||
if (!this.enabled) {
|
||||
return null;
|
||||
}
|
||||
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
|
||||
}
|
||||
|
||||
private async getClient() {
|
||||
if (!this.enabled) {
|
||||
throw new BadRequestException('OAuth2 is not enabled');
|
||||
}
|
||||
|
||||
const issuer = await Issuer.discover(this.issuerUrl);
|
||||
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
|
||||
const metadata = { ...this.clientMetadata };
|
||||
if (algorithms[0] === 'HS256') {
|
||||
metadata.id_token_signed_response_alg = algorithms[0];
|
||||
}
|
||||
|
||||
return new issuer.Client(metadata);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||
|
||||
export class OAuthConfigResponseDto {
|
||||
@ApiResponseProperty()
|
||||
enabled!: boolean;
|
||||
|
||||
@ApiResponseProperty()
|
||||
url?: string;
|
||||
|
||||
@ApiResponseProperty()
|
||||
buttonText?: string;
|
||||
}
|
||||
@ -1,24 +1,23 @@
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { jwtConfig } from '../../config/jwt.config';
|
||||
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { jwtConfig } from '../../config/jwt.config';
|
||||
import { UserRepository, USER_REPOSITORY } from './user-repository';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
const USER_REPOSITORY_PROVIDER = {
|
||||
provide: USER_REPOSITORY,
|
||||
useClass: UserRepository,
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)],
|
||||
controllers: [UserController],
|
||||
providers: [
|
||||
UserService,
|
||||
ImmichJwtService,
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useClass: UserRepository,
|
||||
},
|
||||
],
|
||||
providers: [UserService, ImmichJwtService, USER_REPOSITORY_PROVIDER],
|
||||
exports: [USER_REPOSITORY_PROVIDER],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@ -1 +1,7 @@
|
||||
export const jwtSecret = process.env.JWT_SECRET;
|
||||
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
|
||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
|
||||
export enum AuthType {
|
||||
PASSWORD = 'password',
|
||||
OAUTH = 'oauth',
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue