1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-11 06:10:28 +02:00

feat(server/web): add oauth defaultStorageQuota and storageQuotaClaim (#7548)

* feat(server/web): add oauth defaultStorageQuota and storageQuotaClaim

* feat(server/web): fix format and use domain.util constants

* address some pr feedback

* simplify oauth storage quota logic

* adding tests and pr feedback

* chore: cleanup

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Sam Holton 2024-03-01 19:46:07 -05:00 committed by GitHub
parent 8b02f18e99
commit 7303fab9d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 177 additions and 18 deletions

View File

@ -67,9 +67,11 @@ Once you have a new OAuth client application configured, Immich can be configure
| Client Secret | string | (required) | Required. Client Secret (previous step) | | Client Secret | string | (required) | Required. Client Secret (previous step) |
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) | | Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) | | Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web | | Button Text | string | Login with OAuth | Text for the OAuth button on the web |
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in | | Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
| Storage Claim | string | preferred_username | Claim mapping for the user's storage label |
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process | | [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
| [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI | | [Mobile Redirect URI Override](#mobile-redirect-uri) | URL | (empty) | Http(s) alternative mobile redirect URI |

View File

@ -95,13 +95,16 @@ The default configuration looks like this:
"issuerUrl": "", "issuerUrl": "",
"clientId": "", "clientId": "",
"clientSecret": "", "clientSecret": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"scope": "openid email profile", "scope": "openid email profile",
"signingAlgorithm": "RS256",
"storageLabelClaim": "preferred_username", "storageLabelClaim": "preferred_username",
"storageQuotaClaim": "immich_quota",
"defaultStorageQuota": 0,
"buttonText": "Login with OAuth", "buttonText": "Login with OAuth",
"autoRegister": true, "autoRegister": true,
"autoLaunch": false "autoLaunch": false,
"mobileOverrideEnabled": false,
"mobileRedirectUri": ""
}, },
"passwordLogin": { "passwordLogin": {
"enabled": true "enabled": true

Binary file not shown.

View File

@ -9930,6 +9930,9 @@
"clientSecret": { "clientSecret": {
"type": "string" "type": "string"
}, },
"defaultStorageQuota": {
"type": "number"
},
"enabled": { "enabled": {
"type": "boolean" "type": "boolean"
}, },
@ -9950,6 +9953,9 @@
}, },
"storageLabelClaim": { "storageLabelClaim": {
"type": "string" "type": "string"
},
"storageQuotaClaim": {
"type": "string"
} }
}, },
"required": [ "required": [
@ -9958,13 +9964,15 @@
"buttonText", "buttonText",
"clientId", "clientId",
"clientSecret", "clientSecret",
"defaultStorageQuota",
"enabled", "enabled",
"issuerUrl", "issuerUrl",
"mobileOverrideEnabled", "mobileOverrideEnabled",
"mobileRedirectUri", "mobileRedirectUri",
"scope", "scope",
"signingAlgorithm", "signingAlgorithm",
"storageLabelClaim" "storageLabelClaim",
"storageQuotaClaim"
], ],
"type": "object" "type": "object"
}, },

View File

@ -877,6 +877,7 @@ export type SystemConfigOAuthDto = {
buttonText: string; buttonText: string;
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
defaultStorageQuota: number;
enabled: boolean; enabled: boolean;
issuerUrl: string; issuerUrl: string;
mobileOverrideEnabled: boolean; mobileOverrideEnabled: boolean;
@ -884,6 +885,7 @@ export type SystemConfigOAuthDto = {
scope: string; scope: string;
signingAlgorithm: string; signingAlgorithm: string;
storageLabelClaim: string; storageLabelClaim: string;
storageQuotaClaim: string;
}; };
export type SystemConfigPasswordLoginDto = { export type SystemConfigPasswordLoginDto = {
enabled: boolean; enabled: boolean;

View File

@ -15,6 +15,7 @@ import {
newUserTokenRepositoryMock, newUserTokenRepositoryMock,
sharedLinkStub, sharedLinkStub,
systemConfigStub, systemConfigStub,
userDto,
userStub, userStub,
userTokenStub, userTokenStub,
} from '@test'; } from '@test';
@ -62,14 +63,13 @@ describe('AuthService', () => {
let userTokenMock: jest.Mocked<IUserTokenRepository>; let userTokenMock: jest.Mocked<IUserTokenRepository>;
let shareMock: jest.Mocked<ISharedLinkRepository>; let shareMock: jest.Mocked<ISharedLinkRepository>;
let keyMock: jest.Mocked<IKeyRepository>; let keyMock: jest.Mocked<IKeyRepository>;
let callbackMock: jest.Mock;
afterEach(() => { let callbackMock: jest.Mock;
jest.resetModules(); let userinfoMock: jest.Mock;
});
beforeEach(async () => { beforeEach(async () => {
callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' }); callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
userinfoMock = jest.fn().mockResolvedValue({ sub, email });
jest.spyOn(generators, 'state').mockReturnValue('state'); jest.spyOn(generators, 'state').mockReturnValue('state');
jest.spyOn(Issuer, 'discover').mockResolvedValue({ jest.spyOn(Issuer, 'discover').mockResolvedValue({
@ -83,7 +83,7 @@ describe('AuthService', () => {
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'), authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
callbackParams: jest.fn().mockReturnValue({ state: 'state' }), callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
callback: callbackMock, callback: callbackMock,
userinfo: jest.fn().mockResolvedValue({ sub, email }), userinfo: userinfoMock,
}), }),
} as any); } as any);
@ -491,6 +491,74 @@ describe('AuthService', () => {
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
}); });
it('should use the default quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithDefaultStorageQuota);
});
it('should ignore an invalid storage quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userinfoMock.mockResolvedValue({ sub, email, immich_quota: 'abc' });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithDefaultStorageQuota);
});
it('should ignore a negative quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userinfoMock.mockResolvedValue({ sub, email, immich_quota: -5 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithDefaultStorageQuota);
});
it('should ignore a 0 quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userinfoMock.mockResolvedValue({ sub, email, immich_quota: 0 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithDefaultStorageQuota);
});
it('should use a valid storage quota', async () => {
configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userinfoMock.mockResolvedValue({ sub, email, immich_quota: 5 });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.create).toHaveBeenCalledWith(userDto.userWithStorageQuotaClaim);
});
}); });
describe('link', () => { describe('link', () => {

View File

@ -7,11 +7,13 @@ import {
InternalServerErrorException, InternalServerErrorException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { isNumber, isString } from 'class-validator';
import cookieParser from 'cookie'; import cookieParser from 'cookie';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { AccessCore, Permission } from '../access'; import { AccessCore, Permission } from '../access';
import { HumanReadableSize } from '../domain.util';
import { import {
IAccessRepository, IAccessRepository,
ICryptoRepository, ICryptoRepository,
@ -64,6 +66,12 @@ interface OAuthProfile extends UserinfoResponse {
email: string; email: string;
} }
interface ClaimOptions<T> {
key: string;
default: T;
isValid: (value: unknown) => boolean;
}
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private access: AccessCore; private access: AccessCore;
@ -234,9 +242,11 @@ export class AuthService {
} }
} }
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth;
// register new user // register new user
if (!user) { if (!user) {
if (!config.oauth.autoRegister) { if (!autoRegister) {
this.logger.warn( this.logger.warn(
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`, `Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
); );
@ -246,17 +256,24 @@ export class AuthService {
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`); this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
this.logger.verbose(`OAuth Profile: ${JSON.stringify(profile)}`); this.logger.verbose(`OAuth Profile: ${JSON.stringify(profile)}`);
let storageLabel: string | null = profile[config.oauth.storageLabelClaim as keyof OAuthProfile] as string; const storageLabel = this.getClaim(profile, {
if (typeof storageLabel !== 'string') { key: storageLabelClaim,
storageLabel = null; default: '',
} isValid: isString,
});
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
default: defaultStorageQuota,
isValid: (value: unknown) => isNumber(value) && value > 0,
});
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`; const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.userCore.createUser({ user = await this.userCore.createUser({
name: userName, name: userName,
email: profile.email, email: profile.email,
oauthId: profile.sub, oauthId: profile.sub,
storageLabel, quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null,
storageLabel: storageLabel || null,
}); });
} }
@ -443,4 +460,9 @@ export class AuthService {
} }
return [accessTokenCookie, authTypeCookie, isAuthenticatedCookie]; return [accessTokenCookie, authTypeCookie, isAuthenticatedCookie];
} }
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {
const value = profile[options.key as keyof OAuthProfile];
return options.isValid(value) ? (value as T) : options.default;
}
} }

View File

@ -1,4 +1,4 @@
import { IsBoolean, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator'; import { IsBoolean, IsNotEmpty, IsNumber, IsString, IsUrl, Min, ValidateIf } from 'class-validator';
const isEnabled = (config: SystemConfigOAuthDto) => config.enabled; const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled; const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
@ -23,6 +23,10 @@ export class SystemConfigOAuthDto {
@IsString() @IsString()
clientSecret!: string; clientSecret!: string;
@IsNumber()
@Min(0)
defaultStorageQuota!: number;
@IsBoolean() @IsBoolean()
enabled!: boolean; enabled!: boolean;
@ -47,4 +51,7 @@ export class SystemConfigOAuthDto {
@IsString() @IsString()
storageLabelClaim!: string; storageLabelClaim!: string;
@IsString()
storageQuotaClaim!: string;
} }

View File

@ -93,6 +93,7 @@ export const defaults = Object.freeze<SystemConfig>({
buttonText: 'Login with OAuth', buttonText: 'Login with OAuth',
clientId: '', clientId: '',
clientSecret: '', clientSecret: '',
defaultStorageQuota: 0,
enabled: false, enabled: false,
issuerUrl: '', issuerUrl: '',
mobileOverrideEnabled: false, mobileOverrideEnabled: false,
@ -100,6 +101,7 @@ export const defaults = Object.freeze<SystemConfig>({
scope: 'openid email profile', scope: 'openid email profile',
signingAlgorithm: 'RS256', signingAlgorithm: 'RS256',
storageLabelClaim: 'preferred_username', storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
}, },
passwordLogin: { passwordLogin: {
enabled: true, enabled: true,

View File

@ -93,6 +93,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
buttonText: 'Login with OAuth', buttonText: 'Login with OAuth',
clientId: '', clientId: '',
clientSecret: '', clientSecret: '',
defaultStorageQuota: 0,
enabled: false, enabled: false,
issuerUrl: '', issuerUrl: '',
mobileOverrideEnabled: false, mobileOverrideEnabled: false,
@ -100,6 +101,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
scope: 'openid email profile', scope: 'openid email profile',
signingAlgorithm: 'RS256', signingAlgorithm: 'RS256',
storageLabelClaim: 'preferred_username', storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota',
}, },
passwordLogin: { passwordLogin: {
enabled: true, enabled: true,

View File

@ -80,6 +80,7 @@ export enum SystemConfigKey {
OAUTH_BUTTON_TEXT = 'oauth.buttonText', OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_CLIENT_ID = 'oauth.clientId', OAUTH_CLIENT_ID = 'oauth.clientId',
OAUTH_CLIENT_SECRET = 'oauth.clientSecret', OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
OAUTH_DEFAULT_STORAGE_QUOTA = 'oauth.defaultStorageQuota',
OAUTH_ENABLED = 'oauth.enabled', OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl', OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled', OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
@ -87,6 +88,7 @@ export enum SystemConfigKey {
OAUTH_SCOPE = 'oauth.scope', OAUTH_SCOPE = 'oauth.scope',
OAUTH_SIGNING_ALGORITHM = 'oauth.signingAlgorithm', OAUTH_SIGNING_ALGORITHM = 'oauth.signingAlgorithm',
OAUTH_STORAGE_LABEL_CLAIM = 'oauth.storageLabelClaim', OAUTH_STORAGE_LABEL_CLAIM = 'oauth.storageLabelClaim',
OAUTH_STORAGE_QUOTA_CLAIM = 'oauth.storageQuotaClaim',
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled', PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
@ -227,6 +229,7 @@ export interface SystemConfig {
buttonText: string; buttonText: string;
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
defaultStorageQuota: number;
enabled: boolean; enabled: boolean;
issuerUrl: string; issuerUrl: string;
mobileOverrideEnabled: boolean; mobileOverrideEnabled: boolean;
@ -234,6 +237,7 @@ export interface SystemConfig {
scope: string; scope: string;
signingAlgorithm: string; signingAlgorithm: string;
storageLabelClaim: string; storageLabelClaim: string;
storageQuotaClaim: string;
}; };
passwordLogin: { passwordLogin: {
enabled: boolean; enabled: boolean;

View File

@ -22,6 +22,11 @@ export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
{ key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' }, { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' },
{ key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' },
], ],
withDefaultStorageQuota: [
{ key: SystemConfigKey.OAUTH_ENABLED, value: true },
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
{ key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 },
],
libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }], libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }],
libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }], libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }],
}; };

View File

@ -23,6 +23,20 @@ export const userDto = {
name: 'User with quota', name: 'User with quota',
quotaSizeInBytes: 42, quotaSizeInBytes: 42,
}, },
userWithDefaultStorageQuota: {
email: 'test@immich.com',
name: ' ',
oauthId: 'my-auth-user-sub',
quotaSizeInBytes: 1_073_741_824,
storageLabel: null,
},
userWithStorageQuotaClaim: {
email: 'test@immich.com',
name: ' ',
oauthId: 'my-auth-user-sub',
quotaSizeInBytes: 5_368_709_120,
storageLabel: null,
},
}; };
export const userStub = { export const userStub = {

View File

@ -3,7 +3,7 @@
</script> </script>
Apply the current Apply the current
<a href={`${AppRoute.ADMIN_SETTINGS}?open=storageTemplate`} class="text-immich-primary dark:text-immich-dark-primary" <a href="{AppRoute.ADMIN_SETTINGS}?open=storageTemplate" class="text-immich-primary dark:text-immich-dark-primary"
>Storage template</a >Storage template</a
> >
to previously uploaded assets to previously uploaded assets

View File

@ -130,6 +130,26 @@
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)} isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/> />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE QUOTA CLAIM"
desc="Automatically set the user's storage quota to the value of this claim."
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageQuotaClaim == savedConfig.oauth.storageQuotaClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="DEFAULT STORAGE QUOTA (GiB)"
desc="Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota)."
bind:value={config.oauth.defaultStorageQuota}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
/>
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT" label="BUTTON TEXT"