From 9619c2433fd3d46f0e4966d2e88f22f6c893ea80 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Tue, 12 Dec 2023 11:23:50 +0100 Subject: [PATCH] root: add support for storing media files in S3 Signed-off-by: Marc 'risson' Schmitt --- authentik/lib/config.py | 8 +- authentik/lib/default.yml | 20 +++- authentik/root/settings.py | 47 +++++++- authentik/root/storages.py | 80 +++++++++++++ internal/config/struct.go | 15 ++- internal/web/static.go | 9 +- poetry.lock | 119 +++++++++++++++++++- pyproject.toml | 1 + scripts/create_bucket.sh | 16 +++ scripts/docker-compose.yml | 17 +++ scripts/generate_config.py | 13 +++ website/docs/installation/configuration.mdx | 18 ++- website/docs/installation/storage-s3.md | 104 +++++++++++++++++ website/docs/releases/2024/v2024.1.md | 4 + website/sidebars.js | 1 + 15 files changed, 450 insertions(+), 22 deletions(-) create mode 100644 authentik/root/storages.py create mode 100644 scripts/create_bucket.sh create mode 100644 website/docs/installation/storage-s3.md diff --git a/authentik/lib/config.py b/authentik/lib/config.py index dd5500f8e..ec96b0e9b 100644 --- a/authentik/lib/config.py +++ b/authentik/lib/config.py @@ -34,6 +34,7 @@ REDIS_ENV_KEYS = [ f"{ENV_PREFIX}_REDIS__TLS_REQS", ] +# Old key -> new key DEPRECATIONS = { "redis.broker_url": "broker.url", "redis.broker_transport_options": "broker.transport_options", @@ -200,12 +201,13 @@ class ConfigLoader: root[key] = value return root - def refresh(self, key: str): + def refresh(self, key: str, default=None, sep=".") -> Any: """Update a single value""" - attr: Attr = get_path_from_dict(self.raw, key) + attr: Attr = get_path_from_dict(self.raw, key, sep=sep, default=Attr(default)) if attr.source_type != Attr.Source.URI: - return + return attr.value attr.value = self.parse_uri(attr.source).value + return attr.value def parse_uri(self, value: str) -> Attr: """Parse string values which start with a URI""" diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 7573888ae..767c4880c 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -53,9 +53,6 @@ cache: # result_backend: # url: "" -paths: - media: ./media - debug: false remote_debug: false @@ -130,3 +127,20 @@ web: worker: concurrency: 2 + +storage: + media: + backend: file # or s3 + file: + path: ./media + s3: + # How to talk to s3 + # region: "us-east-1" + # use_ssl: True + # endpoint: "https://s3.us-east-1.amazonaws.com" + # access_key: "" + # secret_key: "" + # bucket_name: "authentik-media" + # How to render file URLs + # custom_domain: null + secure_urls: True diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 526cf27ce..015a45c56 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -16,8 +16,6 @@ from authentik.lib.utils.reflection import get_env from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP BASE_DIR = Path(__file__).absolute().parent.parent.parent -STATICFILES_DIRS = [BASE_DIR / Path("web")] -MEDIA_ROOT = BASE_DIR / Path("media") DEBUG = CONFIG.get_bool("debug") SECRET_KEY = CONFIG.get("secret_key") @@ -372,8 +370,51 @@ if _ERROR_REPORTING: # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.1/howto/static-files/ +STATICFILES_DIRS = [BASE_DIR / Path("web")] STATIC_URL = "/static/" -MEDIA_URL = "/media/" + +STORAGES = { + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} + + +# Media files + +if CONFIG.get("storage.media.backend", "file") == "s3": + STORAGES["default"] = { + "BACKEND": "authentik.root.storages.S3Storage", + "OPTIONS": { + # How to talk to S3 + "session_profile": CONFIG.get("storage.media.s3.session_profile", None), + "access_key": CONFIG.get("storage.media.s3.access_key", None), + "secret_key": CONFIG.get("storage.media.s3.secret_key", None), + "security_token": CONFIG.get("storage.media.s3.security_token", None), + "region_name": CONFIG.get("storage.media.s3.region", None), + "use_ssl": CONFIG.get_bool("storage.media.s3.use_ssl", True), + "endpoint_url": CONFIG.get("storage.media.s3.endpoint", None), + "bucket_name": CONFIG.get("storage.media.s3.bucket_name"), + "default_acl": "private", + "querystring_auth": True, + "signature_version": "s3v4", + "file_overwrite": False, + "location": "media", + "url_protocol": "https:" + if CONFIG.get("storage.media.s3.secure_urls", True) + else "http:", + "custom_domain": CONFIG.get("storage.media.s3.custom_domain", None), + }, + } +# Fallback on file storage backend +else: + STORAGES["default"] = { + "BACKEND": "django.core.files.storage.FileSystemStorage", + "OPTIONS": { + "location": Path(CONFIG.get("storage.media.file.path")), + "base_url": "/media/", + }, + } TEST = False TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner" diff --git a/authentik/root/storages.py b/authentik/root/storages.py new file mode 100644 index 000000000..5a2c56a58 --- /dev/null +++ b/authentik/root/storages.py @@ -0,0 +1,80 @@ +"""authentik storage backends""" +from storages.backends.s3 import S3Storage as BaseS3Storage +from storages.utils import clean_name + +from authentik.lib.config import CONFIG + + +# pylint: disable=abstract-method +class S3Storage(BaseS3Storage): + """S3 storage backend""" + + @property + def session_profile(self) -> str | None: + """Get session profile""" + return CONFIG.refresh("storage.media.s3.session_profile", None) + + @session_profile.setter + def session_profile(self, value: str): + pass + + @property + def access_key(self) -> str | None: + """Get access key""" + return CONFIG.refresh("storage.media.s3.access_key", None) + + @access_key.setter + def access_key(self, value: str): + pass + + @property + def secret_key(self) -> str | None: + """Get secret key""" + return CONFIG.refresh("storage.media.s3.secret_key", None) + + @secret_key.setter + def secret_key(self, value: str): + pass + + @property + def security_token(self) -> str | None: + """Get security token""" + return CONFIG.refresh("storage.media.s3.security_token", None) + + @security_token.setter + def security_token(self, value: str): + pass + + # This is a fix for https://github.com/jschneier/django-storages/pull/839 + # pylint: disable=arguments-differ,no-member + def url(self, name, parameters=None, expire=None, http_method=None): + # Preserve the trailing slash after normalizing the path. + name = self._normalize_name(clean_name(name)) + params = parameters.copy() if parameters else {} + if expire is None: + expire = self.querystring_expire + + params["Bucket"] = self.bucket.name + params["Key"] = name + url = self.bucket.meta.client.generate_presigned_url( + "get_object", + Params=params, + ExpiresIn=expire, + HttpMethod=http_method, + ) + + if self.custom_domain: + # Key parameter can't be empty. Use "/" and remove it later. + params["Key"] = "/" + root_url_signed = self.bucket.meta.client.generate_presigned_url( + "get_object", Params=params, ExpiresIn=expire + ) + # Remove signing parameter and previously added key "/". + root_url = self._strip_signing_parameters(root_url_signed)[:-1] + # Replace bucket domain with custom domain. + custom_url = "{}//{}/".format(self.url_protocol, self.custom_domain) + url = url.replace(root_url, custom_url) + + if self.querystring_auth: + return url + return self._strip_signing_parameters(url) diff --git a/internal/config/struct.go b/internal/config/struct.go index e7be9733e..2931c7a7a 100644 --- a/internal/config/struct.go +++ b/internal/config/struct.go @@ -2,7 +2,7 @@ package config type Config struct { // Core specific config - Paths PathsConfig `yaml:"paths"` + Storage StorageConfig `yaml:"storage"` LogLevel string `yaml:"log_level" env:"AUTHENTIK_LOG_LEVEL"` ErrorReporting ErrorReportingConfig `yaml:"error_reporting"` Redis RedisConfig `yaml:"redis"` @@ -45,8 +45,17 @@ type ListenConfig struct { TrustedProxyCIDRs []string `yaml:"trusted_proxy_cidrs" env:"AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS"` } -type PathsConfig struct { - Media string `yaml:"media"` +type StorageConfig struct { + Media StorageMediaConfig `yaml:"media"` +} + +type StorageMediaConfig struct { + Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE_MEDIA_BACKEND"` + File StorageFileConfig `yaml:"file"` +} + +type StorageFileConfig struct { + Path string `yaml:"path" env:"AUTHENTIK_STORAGE_MEDIA_FILE_PATH"` } type ErrorReportingConfig struct { diff --git a/internal/web/static.go b/internal/web/static.go index c4f6dbcf4..c4c79efe0 100644 --- a/internal/web/static.go +++ b/internal/web/static.go @@ -6,6 +6,7 @@ import ( "github.com/go-http-utils/etag" "github.com/gorilla/mux" + "goauthentik.io/internal/config" "goauthentik.io/internal/constants" "goauthentik.io/internal/utils/web" @@ -17,8 +18,6 @@ func (ws *WebServer) configureStatic() { statRouter.Use(ws.staticHeaderMiddleware) indexLessRouter := statRouter.NewRoute().Subrouter() indexLessRouter.Use(web.DisableIndex) - // Media files, always local - fs := http.FileServer(http.Dir(config.Get().Paths.Media)) distFs := http.FileServer(http.Dir("./web/dist")) distHandler := http.StripPrefix("/static/dist/", distFs) authentikHandler := http.StripPrefix("/static/authentik/", http.FileServer(http.Dir("./web/authentik"))) @@ -35,7 +34,11 @@ func (ws *WebServer) configureStatic() { indexLessRouter.PathPrefix("/if/admin/assets").Handler(http.StripPrefix("/if/admin", distFs)) indexLessRouter.PathPrefix("/if/user/assets").Handler(http.StripPrefix("/if/user", distFs)) - indexLessRouter.PathPrefix("/media/").Handler(http.StripPrefix("/media", fs)) + // Media files, if backend is file + if config.Get().Storage.Media.Backend == "file" { + fsMedia := http.FileServer(http.Dir(config.Get().Storage.Media.File.Path)) + indexLessRouter.PathPrefix("/media/").Handler(http.StripPrefix("/media", fsMedia)) + } statRouter.PathPrefix("/if/help/").Handler(http.StripPrefix("/if/help/", helpHandler)) statRouter.PathPrefix("/help").Handler(http.RedirectHandler("/if/help/", http.StatusMovedPermanently)) diff --git a/poetry.lock b/poetry.lock index 500e2e832..f883c8507 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. [[package]] name = "aiohttp" @@ -457,6 +457,44 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "boto3" +version = "1.33.12" +description = "The AWS SDK for Python" +optional = false +python-versions = ">= 3.7" +files = [ + {file = "boto3-1.33.12-py3-none-any.whl", hash = "sha256:475efcff30401041e9c348e20613eca90ab14a224e2f978ca80de98ba3499435"}, + {file = "boto3-1.33.12.tar.gz", hash = "sha256:2225edaea2fa17274f62707c12d9f7803c998af7089fe8a1ec8e4f1ebf47677e"}, +] + +[package.dependencies] +botocore = ">=1.33.12,<1.34.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.8.2,<0.9.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.33.12" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">= 3.7" +files = [ + {file = "botocore-1.33.12-py3-none-any.whl", hash = "sha256:48b9cfb9c5f7f9634a71782f16a324acb522b65856ad46be69efe04c3322b23c"}, + {file = "botocore-1.33.12.tar.gz", hash = "sha256:067c94fa88583c04ae897d48a11d2be09f280363b8e794b82d78d631d3a3e910"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.1", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.19.17)"] + [[package]] name = "bump2version" version = "1.0.1" @@ -1217,6 +1255,30 @@ Django = ">=3.2" gprof2dot = ">=2017.09.19" sqlparse = "*" +[[package]] +name = "django-storages" +version = "1.14.2" +description = "Support for many storage backends in Django" +optional = false +python-versions = ">=3.7" +files = [ + {file = "django-storages-1.14.2.tar.gz", hash = "sha256:51b36af28cc5813b98d5f3dfe7459af638d84428c8df4a03990c7d74d1bea4e5"}, + {file = "django_storages-1.14.2-py3-none-any.whl", hash = "sha256:1db759346b52ada6c2efd9f23d8241ecf518813eb31db9e2589207174f58f6ad"}, +] + +[package.dependencies] +boto3 = {version = ">=1.4.4", optional = true, markers = "extra == \"s3\""} +Django = ">=3.2" + +[package.extras] +azure = ["azure-core (>=1.13)", "azure-storage-blob (>=12)"] +boto3 = ["boto3 (>=1.4.4)"] +dropbox = ["dropbox (>=7.2.1)"] +google = ["google-cloud-storage (>=1.27)"] +libcloud = ["apache-libcloud"] +s3 = ["boto3 (>=1.4.4)"] +sftp = ["paramiko (>=1.15)"] + [[package]] name = "djangorestframework" version = "3.14.0" @@ -1800,6 +1862,17 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -3445,6 +3518,23 @@ files = [ {file = "ruff-0.1.8.tar.gz", hash = "sha256:f7ee467677467526cfe135eab86a40a0e8db43117936ac4f9b469ce9cdb3fb62"}, ] +[[package]] +name = "s3transfer" +version = "0.8.2" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">= 3.7" +files = [ + {file = "s3transfer-0.8.2-py3-none-any.whl", hash = "sha256:c9e56cbe88b28d8e197cf841f1f0c130f246595e77ae5b5a05b69fe7cb83de76"}, + {file = "s3transfer-0.8.2.tar.gz", hash = "sha256:368ac6876a9e9ed91f6bc86581e319be08188dc60d50e0d56308ed5765446283"}, +] + +[package.dependencies] +botocore = ">=1.33.2,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] + [[package]] name = "selenium" version = "4.16.0" @@ -3865,23 +3955,40 @@ files = [ [[package]] name = "urllib3" -version = "2.1.0" +version = "2.0.7" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, + {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, ] [package.dependencies] +certifi = {version = "*", optional = true, markers = "extra == \"secure\""} +cryptography = {version = ">=1.9", optional = true, markers = "extra == \"secure\""} +idna = {version = ">=2.0.0", optional = true, markers = "extra == \"secure\""} +pyopenssl = {version = ">=17.1.0", optional = true, markers = "extra == \"secure\""} pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} +urllib3-secure-extra = {version = "*", optional = true, markers = "extra == \"secure\""} [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "urllib3-secure-extra" +version = "0.1.0" +description = "Marker library to detect whether urllib3 was installed with the deprecated [secure] extra" +optional = false +python-versions = "*" +files = [ + {file = "urllib3-secure-extra-0.1.0.tar.gz", hash = "sha256:ee9409cbfeb4b8609047be4c32fb4317870c602767e53fd8a41005ebe6a41dff"}, + {file = "urllib3_secure_extra-0.1.0-py2.py3-none-any.whl", hash = "sha256:f7adcb108b4d12a4b26b99eb60e265d087f435052a76aefa396b6ee85e9a6ef9"}, +] + [[package]] name = "uvicorn" version = "0.24.0.post1" @@ -4437,4 +4544,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.12" -content-hash = "d0fe6ae1be389f8a5ca5112aa90555e2ce0a4f336f07a1da9c43dd521e9d9340" +content-hash = "66807245a194176dc94c47b22eb0c8a37732afc3003d81fb4db96fe570a06718" diff --git a/pyproject.toml b/pyproject.toml index 519dfee67..edaae4edc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,7 @@ django-guardian = "*" django-model-utils = "*" django-prometheus = "*" django-redis = "*" +django-storages = { extras = ["s3"], version = "*" } djangorestframework = "*" djangorestframework-guardian = "*" docker = "*" diff --git a/scripts/create_bucket.sh b/scripts/create_bucket.sh new file mode 100644 index 000000000..0d4979faf --- /dev/null +++ b/scripts/create_bucket.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env -S bash -e + +AWS_ACCESS_KEY_ID=accessKey1 AWS_SECRET_ACCESS_KEY=secretKey1 aws \ + s3api \ + --endpoint-url http://localhost:8020 \ + create-bucket \ + --acl private \ + --bucket authentik-media + +AWS_ACCESS_KEY_ID=accessKey1 AWS_SECRET_ACCESS_KEY=secretKey1 aws \ + s3api \ + --endpoint-url http://localhost:8020 \ + put-bucket-cors \ + --bucket authentik-media \ + --cors-configuration \ + '{"CORSRules": [{"AllowedOrigins": ["*"], "AllowedHeaders": ["Authorization"], "AllowedMethods": ["GET"], "MaxAgeSeconds": 3000}]}' diff --git a/scripts/docker-compose.yml b/scripts/docker-compose.yml index 658d7a0db..77495ce08 100644 --- a/scripts/docker-compose.yml +++ b/scripts/docker-compose.yml @@ -18,7 +18,24 @@ services: ports: - 127.0.0.1:6379:6379 restart: always + s3: + container_name: s3 + image: docker.io/zenko/cloudserver + environment: + REMOTE_MANAGEMENT_DISABLE: "1" + SCALITY_ACCESS_KEY_ID: accessKey1 + SCALITY_SECRET_ACCESS_KEY: secretKey1 + ports: + - 8020:8000 + volumes: + - s3-data:/usr/src/app/localData + - s3-metadata:/usr/scr/app/localMetadata + restart: always volumes: db-data: driver: local + s3-data: + driver: local + s3-metadata: + driver: local diff --git a/scripts/generate_config.py b/scripts/generate_config.py index 187eb3ba5..5f4735b40 100644 --- a/scripts/generate_config.py +++ b/scripts/generate_config.py @@ -18,6 +18,19 @@ with open("local.env.yml", "w", encoding="utf-8") as _config: "blueprints_dir": "./blueprints", "cert_discovery_dir": "./certs", "geoip": "tests/GeoLite2-City-Test.mmdb", + "storage": { + "media": { + "backend": "file", + "s3": { + "endpoint": "http://localhost:8020", + "access_key": "accessKey1", + "secret_key": "secretKey1", + "bucket_name": "authentik-media", + "custom_domain": "localhost:8020/authentik-media", + "secure_urls": False, + }, + }, + }, }, _config, default_flow_style=False, diff --git a/website/docs/installation/configuration.mdx b/website/docs/installation/configuration.mdx index 28d8e5f50..e2d15eb7c 100644 --- a/website/docs/installation/configuration.mdx +++ b/website/docs/installation/configuration.mdx @@ -116,7 +116,7 @@ To check if your config has been applied correctly, you can run the following co `AUTHENTIK_REDIS__CACHE_TIMEOUT_REPUTATION` only applies to the cache expiry, see [`AUTHENTIK_REPUTATION__EXPIRY`](#authentik_reputation__expiry) to control how long reputation is persisted for. ::: -## Listen Setting +## Listen Settings - `AUTHENTIK_LISTEN__HTTP`: Listening address:port (e.g. `0.0.0.0:9000`) for HTTP (Applies to Server and Proxy outpost) - `AUTHENTIK_LISTEN__HTTPS`: Listening address:port (e.g. `0.0.0.0:9443`) for HTTPS (Applies to Server and Proxy outpost) @@ -130,6 +130,22 @@ To check if your config has been applied correctly, you can run the following co Requests directly coming from one an address within a CIDR specified here are able to set proxy headers, such as `X-Forwarded-For`. Requests coming from other addresses will not be able to set these headers. +## Media Storage Settings + +These settings affect where media files are stored. Those files include applications and sources icons. By default, they are stored on disk in the `/media` directory of the authentik container. S3 storage is also supported. + +- `AUTHENTIK_STORAGE_MEDIA_BACKEND`: Where to store files. Valid values are `file` and `s3`. For `file` storage, files are stored in a `/media` directory in the container. For `s3`, see below. +- `AUTHENTIK_STORAGE_MEDIA_S3_REGION`: S3 region where the bucket has been created. May be omitted depending on which S3 provider you use. No default. +- `AUTHENTIK_STORAGE_MEDIA_S3_USE__SSL`: Whether to use HTTPS when talking to the S3 storage providers. Defaults to `true`. +- `AUTHENTIK_STORAGE_MEDIA_S3_ENDPOINT`: Endpoint to use to talk to the S3 storage provider. Override the previous region and use_ssl settings. Must be a valid URL in the form of `https://s3.provider`. No default. +- `AUTHENTIK_STORAGE_MEDIA_S3_SESSION__PROFILE`: Profile to use when using AWS SDK authentication. No default. Supports hot-reloading. +- `AUTHENTIK_STORAGE_MEDIA_S3_ACCESS__KEY`: Access key to authenticate to S3. May be omitted if using AWS SDK authentication. Supports hot-reloading. +- `AUTHENTIK_STORAGE_MEDIA_S3_SECRET__KEY`: Secret key to authenticate to S3. May be omitted if using AWS SDK authentication. Supports hot-reloading. +- `AUTHENTIK_STORAGE_MEDIA_S3_SECURITY__TOKEN`: Security token to authenticate to S3. May be omitted. Supports hot-reloading. +- `AUTHENTIK_STORAGE_MEDIA_S3_BUCKET__NAME`: Name of the bucket to use to store files. +- `AUTHENTIK_STORAGE_MEDIA_S3_CUSTOM__DOMAIN`: Domain to use to create URLs for users. Mainly useful for non-AWS providers. May include a port. Must include the bucket. Example: `s3.company:8080/authentik-media`. +- `AUTHENTIK_STORAGE_MEDIA_S3_SECURE__URLS`: Whether URLS created for users use `http` or `https`. Defaults to `true`. + ## authentik Settings ### `AUTHENTIK_SECRET_KEY` diff --git a/website/docs/installation/storage-s3.md b/website/docs/installation/storage-s3.md new file mode 100644 index 000000000..bbe536369 --- /dev/null +++ b/website/docs/installation/storage-s3.md @@ -0,0 +1,104 @@ +--- +title: S3 storage setup +--- + +### Preparation + +First, create a user on your S3 storage provider and get access credentials for S3, hereafter referred as `access_key` and `secret_key`. + +You'll also need to know which endpoint authentik is going to use to access the S3 API, hereafter referred as `https://s3.provider`. + +The bucket in which authentik is going to store files is going to be called `authentik-media`. You may need to change this name depending on your S3 provider limitations. Also, we're suffixing the bucket name with `-media` as authentik currently only stores media files, but may use other buckets in the future. + +The domain used to access authentik is going to be referred to as `authentik.company`. + +You will also need the AWS CLI. + +### S3 configuration + +#### Bucket creation + +Let's create the bucket in which authentik is going to store files: + +```bash +AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider create-bucket --bucket=authentik-media --acl=private +``` + +If using AWS S3, you can omit the `--endpoint-url` option, but may need to specify the `--region` option. If using Google Cloud Storage, refer to its documentation on how to create buckets. + +The bucket ACL is set to private, although that is not strictly necessary, as an ACL associated with each object stored in the bucket will be private as well. + +#### CORS policy + +Next, let's associate a CORS policy to the bucket, to allow the authentik web interface to show images stored in the bucket. + +First, save the following file locally as `cors.json`: + +```json +{ + "CORSRules": [ + { + "AllowedOrigins": ["authentik.company"], + "AllowedHeaders": ["Authorization"], + "AllowedMethods": ["GET"], + "MaxAgeSeconds": 3000 + } + ] +} +``` + +If authentik is accessed from multiple domains, you can add them to the `AllowedOrigins` list. + +Let's apply that policy to the bucket: + +```bash +AWS_ACCESS_KEY_ID=access_key AWS_SECRET_ACCESS_KEY=secret_key aws s3api --endpoint-url=https://s3.provider put-bucket-cors --bucket=authentik-media --cors-configuration=file://cors.json +``` + +### Configuring authentik + +Add the following to your `.env` file: + +```env +AUTHENTIK_STORAGE_MEDIA_BACKEND=s3 +AUTHENTIK_STORAGE_MEDIA_S3_ACCESS__KEY=access_key +AUTHENTIK_STORAGE_MEDIA_S3_SECRET__KEY=secret_key +AUTHENTIK_STORAGE_MEDIA_S3_BUCKET__NAME=authentik-media +``` + +If you're using AWS S3 as your S3 provider, add the following: + +```env +AUTHENTIK_STORAGE_MEDIA_S3_REGION=us-east-1 # Use the region of the bucket +``` + +If you're not using AWS S3 as your S3 provider, add the following: + +```env +AUTHENTIK_STORAGE_MEDIA_S3_ENDPOINT=https://s3.provider +AUTHENTIK_STORAGE_MEDIA_S3_CUSTOM__DOMAIN=s3.provider/authentik-media +``` + +The `ENDPOINT` setting specifies how authentik talks to the S3 provider. + +The `CUSTOM__DOMAIN` setting specifies how URLs are constructed to be shown on the web interface. For example, an object stored at `application-icons/application.png` with a `CUSTOM__DOMAIN` setting of `s3.provider/authentik-media` will result in a URL of `https://s3.provider/authentik-media/application-icons/application.png`. You can also use subdomains for your buckets depending on what your S3 provider offers: `authentik-media.s3.provider`. Whether HTTPS is used is controlled by the `AUTHENTIK_STORAGE_MEDIA_S3_SECURE__URLS` which defaults to true. + +For more control over settings, refer to the [configuration reference](./configuration.mdx#media-storage-settings) + +### Migrating between storage backends + +The following section assumes that the local storage path is `/media` and the bucket name is `authentik-media`. It also assumes you have a working `aws` CLI that can interact with the bucket. + +#### From file to s3 + +Follow the setup steps above, and then migrate the files from your local directory to s3: + +```bash +aws s3 sync /media s3://authentik-media +``` + +#### From s3 to file + +```bash +aws s3 sync s3://authentik-media /media +``` diff --git a/website/docs/releases/2024/v2024.1.md b/website/docs/releases/2024/v2024.1.md index c6ffd0f28..54c488a32 100644 --- a/website/docs/releases/2024/v2024.1.md +++ b/website/docs/releases/2024/v2024.1.md @@ -23,6 +23,10 @@ slug: "/releases/2024.1" Previously the identification stage would only continue if a user matching the user identifier exists. While this was the intended functionality, this release adds an option to continue to the next stage even if no matching user was found. "Pretend" users cannot authenticate nor receive emails, and don't exist in the database. **This feature is enabled by default.** +- S3 file storage + + Media files can now be stored on S3. Follow the [setup guide](../../installation/storage-s3.md) to get started. + ## Upgrading This release does not introduce any new requirements. diff --git a/website/sidebars.js b/website/sidebars.js index fe1b6725e..2816442c7 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -27,6 +27,7 @@ const docsSidebar = { "installation/automated-install", "installation/air-gapped", "installation/monitoring", + "installation/storage-s3", ], }, {