Apache: restrict serving images to authenticated users

I'm trying to figure out a way to restrict access to a media folder in my apache config. The folder takes uploads from from a Django site and image/pdf uploads are displayed in the site to authenticated users. The problem is, that any unauthenticated schmo can navigate to mysite.com/media/images/pic1.jpg. This shouldn't be possible; I've tried a few things to restrict this behavior, but I think I need a pointer or two.

first try : XSendfile

Xsendfile seemed to work, but it (as the name suggests) sends the file for download, then my page that's supposed to display images doesn't load. So it seems this isn't what I need for my usecase.

second try : rewrite rule

I added some rewrite rules to the apache config:

RewriteCond "%{HTTP_REFERER}" "!^$"
RewriteCond "%{HTTP_REFERER}" "!mysite.com/priv/" [NC]
RewriteRule "\.(gif|jpg|png|pdf)$"    "-"   [F,NC]

All the parts of the site that requires authentication are behind the /priv/ path, so my idea was that if this works then navigating to /media/images/pic1.jpg would be rewriten. But this didn't work either mysite.com/media/images/pic1.jpg still shows the image.

third try : environment

I tried something similar with an environment inside the virtualhost:

<VirtualHost *:80>
    ...
    SetEnvIf Referer "mysite\.com\/priv" localreferer
    SetEnvIf Referer ^$ localreferer
    <FilesMatch "\.(jpg|png|gif|pdf)$">
        Require env localreferer
    </FilesMatch>
    ...
</VirtualHost>

But this also didn't work; I can still navigate directly to the image.

fourth try : Require valid-user

I added Require valid-user to the v-host, but I can't figure out how to check it against the Django user model. This after this change, I would get a prompt to log in each time I loaded a page which displays images (but w/out htaccess etc, there's nothing to auth against and no images are displayed on the site.

I then tried to implement what is described here (https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/apache-auth/), but my django project doesn't like WSGIHandler (as opposed to the default get_wsgi_application()). I get a raise AppRegistryNotReady("Apps aren't loaded yet.") error. It seems like this might be the most reasonable approach, but I don't know how to get the WSGIHandler working, or the approach working with the get_wsgi_application().

I'm aware that I could give the files a hard-to-guess uuid-like name, but this seems like a half-assed solution. So, what's my best strategy to restrict access to the media folder so that these images are only linked within the part of the site where users are authenticated?

Ubuntu 20.04, Apache 2.4

| Edit, following some advice |

auth.py

def check_password(environ, username, password):
    print("---->>>---->>>---->>>---->>>---->>> check_password() has been called  <<<----<<<----<<<----<<<----<<<----")

    return True

#from django.contrib.auth.handlers.modwsgi import check_password

Apache logs show that this script is loaded, but the function apparently isn't executed as the print statement doesn't turn up in the logs. I put a stray print statement in this file and in the wsgi.py file to make sure this strategy makes it to the logs, only that which was in the wsgi.py file made it to the log.

vhost:

<VirtualHost *:80>
    ServerName mysite.com
    ServerAlias mysite.com
    DocumentRoot /path/to/docroot/
    
    Alias /static/ /path/to/docroot/static/

    # Not sure if I need this
    Alias /media/ /path/to/docroot/media/

    <Directory /path/to/docroot/static/>
        Require all granted
    </Directory>

    <Directory /path/to/docroot/media/>
        Require all granted
    </Directory>

    # this is my restricted access directory
    <Directory /path/to/docroot/media/priv/>
        AuthType Basic
        AuthName "Top Secret"
        AuthBasicProvider wsgi
        WSGIAuthUserScript /path/to/docroot/mysite/auth.py
        Require valid-user
    </Directory>

    <Directory /path/to/docroot/mysite/>
        <Files "wsgi.py">
            Require all granted
        </Files>
    </Directory>

    WSGIDaemonProcess open-ancestry-web python-home=/path/to/ENV/ python-path=/path/to/docroot/ processes=10 threads=10
    WSGIProcessGroup mysite-pgroup
    WSGIScriptAlias / /path/to/docroot/mysite/wsgi.py

    LogLevel trace8
    ErrorLog "|/bin/rotatelogs -l /path/to/logs/%Y%m%d-%H%M%S_443errors.log 30"
    CustomLog "|/bin/rotatelogs -l /path/to/logs/%Y%m%d-%H%M%S_443access.log 30" combined
</VirtualHost>

|another edit |

I accepted the answer because everything is now functional. There were lot's of moving parts, which caused the initial problem with the answer. (1) The test check_password function wasn't showing up in the apache logs...well it was turning up at /var/log/apache2/error.log instead of the custom logs that were set up. Not sure why, but ok...

(2) My venv wasn't activated properly and I didn't actually notice this because django is installed on the system Python as well. I copied the activate_this.py script from a virtualenv and added it to my venv and added sth like this to my wsgi file

activate_this = '/path/to/ENV/bin/activate_this.py'
with open(activate_this) as f:
    exec(f.read(), {'__file__': activate_this})

With those things fixed, the check_password function works when called from the wsgi.py file. "works" here means that it restricts access to the folder that unauthed users shouldn't have access to. Users still need to provide credentials twice – once in the regular django view, and once in the browser prompt. This is irritating, but actually my question was about restricting access, so I'll leave it for another day.

The answer's suggestion to call check_password from auth.py is not cooperating with my project. I get errors that suggest it's called before wsgi.py – it seems like the venv is not loaded or the settings are not loaded at the time check_password is called.


Solution 1:

this is what get_wsgi_application is doing:

def get_wsgi_application():
    django.setup(set_prefix=False)     # this will lead to "apps_ready=true"
    return WSGIHandler()

it is setting up the django environment before returning the handler.

The following should do the trick in your wsgi.py:

application = get_wsgi_application()
from django.contrib.auth.handlers.modwsgi import check_password
# the sequence is important!!

In fact the problem is the first line in modwsgi.py:

UserModel = auth.get_user_model()

because get_user_model() will check for apps_ready and all that is done in the moment that python executes the file import!

the better way would be to create a seperate auth.py and first check if it is really called by Apache with a simple print that will go to Apache's error.log:

def check_password(environ, username, password):
    print("***********   check_password() has been called  ********")
    return True

Once this is running you can replace it by the import statement and use djangos check_password().

from django.contrib.auth.handlers.modwsgi import check_password

Then something like the following in httpd-vhosts.conf:

<VirtualHost *:80>

   ....

   <Directory path_to_server_root/secret>
        AuthType Basic
        AuthName "Top Secret"
        AuthBasicProvider wsgi
        WSGIAuthUserScript path_to_wsgi/wsgi.py
        Require valid-user
   </Directory>

</VirtualHost>