Skip to content

Commit

Permalink
Closes redimp#90. Proof of concept for reverse-proxy auth.
Browse files Browse the repository at this point in the history
- This commit adds a new auth manager class for authorizing via proxy
  headers `ProxyHeaderAuth` which can be selected by setting the
  `AUTH_METHOD` env var to `PROXY_HEADER`

    - This auth manager looks for the following headers in order to
      create a "pseudo-user" on each request. No users are committed to
      the SQLite database when using this auth manager.

        - `X-OtterWiki-Name` - the name of the user to include on the
          Git commit when editing a page
        - `X-OtterWiki-Email` - the email of the user to include on the
          Git commit when editing a page
        - `X-OtterWiki-Permissions` - a comma separated list of
          permissions to grant to the user

    - The Docker `entrypoint.sh` script has been updated to pass the
      `AUTH_METHOD` config option thru if set in the environment.

- `has_permission(permission, user)` is now a method specific to each
  auth manager

- auth managers now implement a `supported_features()` method to detail
  which features they support (like whether an auth manager allows a
  user to change their name or password, or logout)

    - the features object this method returns is present in all Jinja
      templates as the variable `auth_supported_features`

    - the settings page was updated to prevent a user from editing their
      password and name if it is not supported by the current auth
      manager.

    - the dropdown menu present on all page was updated to hide the
      "logout" button if it is not supported by the current auth manager

- The `test_settings.py` test was updated to tolerate extra whitespace
  • Loading branch information
weaversam8 committed Mar 6, 2024
1 parent ffa7aa7 commit 3ed05d5
Show file tree
Hide file tree
Showing 6 changed files with 221 additions and 115 deletions.
2 changes: 1 addition & 1 deletion docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ for EV in SITE_NAME SITE_LOGO SITE_DESCRIPTION SITE_ICON; do
fi
done
# permissions
for EV in READ_ACCESS WRITE_ACCESS ATTACHMENT_ACCESS; do
for EV in AUTH_METHOD READ_ACCESS WRITE_ACCESS ATTACHMENT_ACCESS; do
if [ ! -z "${!EV}" ]; then
sed -i "/^${EV}.*/d" ${OTTERWIKI_SETTINGS}
echo "${EV} = '${!EV}'" >> ${OTTERWIKI_SETTINGS}
Expand Down
279 changes: 178 additions & 101 deletions otterwiki/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,6 @@ def activated_user_notify_admins(self, name, email):
subject = "New Account Registration - {} - An Otter Wiki".format(app.config["SITE_NAME"])
send_mail(subject=subject, recipients=admin_emails, text_body=text_body)


def _user_needs_approvement(self):
# check if the user needs to be approved by checking
# if beeing REGISTERED is a lower requirement anywhere
Expand All @@ -288,7 +287,6 @@ def user_confirmed_email(self, email):
if app.config['NOTIFY_ADMINS_ON_REGISTER']:
self.activated_user_notify_admins(user.name, user.email)


def handle_register(self, email, name, password1, password2):
# check if email exists
user = self.User.query.filter_by(email=email).first()
Expand Down Expand Up @@ -432,24 +430,191 @@ def handle_recover_password_token(self, token):
toast("Invalid email address.")
return lost_password_form()

def has_permission(self, permission, user):
if user.is_authenticated and user.is_admin:
return True
# check page read permission
if permission.upper() == "READ":
if app.config["READ_ACCESS"].upper() == "ANONYMOUS":
return True
if (
app.config["READ_ACCESS"].upper() == "REGISTERED"
and user.is_authenticated
):
return True
if (
app.config["READ_ACCESS"].upper() == "APPROVED"
and user.is_authenticated
and user.is_approved
):
return True
if user.is_authenticated and user.is_approved and user.allow_read:
return True
# admins have permissions for everything
if user.is_authenticated and user.is_admin:
return True
# check page edit permission
if permission.upper() == "WRITE":
# if you are not allowed to read, you are not allowed to write
if not has_permission("READ"):
return False
if app.config["WRITE_ACCESS"].upper() == "ANONYMOUS":
return True
if (
app.config["WRITE_ACCESS"].upper() == "REGISTERED"
and user.is_authenticated
):
return True
if (
app.config["WRITE_ACCESS"].upper() == "APPROVED"
and user.is_authenticated
and user.is_approved
):
return True
if user.is_authenticated and user.is_approved and user.allow_write:
return True
# admins have permissions for everything
if user.is_authenticated and user.is_admin:
return True
# check upload permission
if permission.upper() == "UPLOAD":
if not has_permission("WRITE"):
return False
if app.config["ATTACHMENT_ACCESS"] == "ANONYMOUS":
return True
if (
app.config["ATTACHMENT_ACCESS"] == "REGISTERED"
and user.is_authenticated
):
return True
if (
app.config["ATTACHMENT_ACCESS"] == "APPROVED"
and user.is_authenticated
and user.is_approved
):
return True
if (
user.is_authenticated
and user.is_approved
and user.allow_upload
):
return True
# admins have permissions for everything
if user.is_authenticated and user.is_admin:
return True
if permission.upper() == "ADMIN":
if user.is_anonymous:
return False
return True == user.is_admin

return False

def supported_features(self):
return {'passwords': True, 'editing': True, 'logout': True}


class ProxyHeaderAuth:
# if logout_link is not provided, hide the logout button
def __init__(self, logout_link=None):
self.logout_link = logout_link

class User(UserMixin):
def __init__(self, name, email, permissions):
self.name = name
self.email = email
self.is_approved = True
self.allow_read = 'READ' in permissions
self.allow_write = 'WRITE' in permissions
self.allow_upload = 'UPLOAD' in permissions
self.is_admin = 'ADMIN' in permissions
self.permissions = permissions

def __repr__(self):
return f"<User '{self.name} <{self.email}>' a:{self.is_admin}>"

def supported_features(self):
return {'passwords': False, 'editing': False, 'logout': False}

# called on every page load
def request_loader(self, req):
if 'x-otterwiki-name' not in req.headers:
return None

if 'x-otterwiki-email' not in req.headers:
return None

if 'x-otterwiki-permissions' in req.headers:
permissions = (
req.headers.get('x-otterwiki-permissions').upper().split(',')
)
else:
permissions = []

return self.User(
name=req.headers.get('x-otterwiki-name'),
email=req.headers.get('x-otterwiki-email'),
permissions=permissions,
)

# we can use the same implementation as above
def get_author(self):
if not current_user.is_authenticated:
return ("Anonymous", "")
return (current_user.name, current_user.email)

# user will be directed to login form first, we should redirect them to handle_login automatically (just a POST to /-/login)
def login_form(self, *args, **kwargs):
if current_user.is_authenticated and self.has_permission(
'READ', current_user
):
return redirect(url_for("index"))
else:
return abort(403)

def settings_form(self):
return render_template(
"settings.html",
title="Settings",
user_list=None, # no users are stored in the database anyways
)

def get_all_user(self):
return [current_user]

def has_permission(self, permission, user):
return permission.upper() in user.permissions


# create login manager
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login" # pyright: ignore
login_manager.login_view = "login" # pyright: ignore

# create auth_manager
if app.config.get("AUTH_METHOD") in ["", "SIMPLE"]:
auth_manager = SimpleAuth()
elif app.config.get("AUTH_METHOD") == "PROXY_HEADER":
auth_manager = ProxyHeaderAuth()
else:
raise RuntimeError("Unknown AUTH_METHOD '{}'".format(app.config.get("AUTH_METHOD")))
raise RuntimeError(
"Unknown AUTH_METHOD '{}'".format(app.config.get("AUTH_METHOD"))
)

#
# proxies
#
@login_manager.user_loader
def user_load_proxy(id):
return auth_manager.user_loader(id)
if hasattr(auth_manager, "user_loader"):

@login_manager.user_loader
def user_load_proxy(id):
return auth_manager.user_loader(id)


elif hasattr(auth_manager, "request_loader"):

@login_manager.request_loader
def request_load_proxy(req):
return auth_manager.request_loader(req)


def login_form(*args, **kwargs):
Expand Down Expand Up @@ -523,102 +688,14 @@ def check_credentials(email, password):
# utils
#
def has_permission(permission, user=current_user):
if user.is_authenticated and user.is_admin:
return True
# check page read permission
if permission.upper() == "READ":
if app.config["READ_ACCESS"].upper() == "ANONYMOUS":
return True
if (
app.config["READ_ACCESS"].upper() == "REGISTERED"
and user.is_authenticated
):
return True
if (
app.config["READ_ACCESS"].upper() == "APPROVED"
and user.is_authenticated
and user.is_approved
):
return True
if (
user.is_authenticated
and user.is_approved
and user.allow_read
):
return True
# admins have permissions for everything
if (
user.is_authenticated
and user.is_admin
):
return True
# check page edit permission
if permission.upper() == "WRITE":
# if you are not allowed to read, you are not allowed to write
if not has_permission("READ"):
return False
if app.config["WRITE_ACCESS"].upper() == "ANONYMOUS":
return True
if (
app.config["WRITE_ACCESS"].upper() == "REGISTERED"
and user.is_authenticated
):
return True
if (
app.config["WRITE_ACCESS"].upper() == "APPROVED"
and user.is_authenticated
and user.is_approved
):
return True
if (
user.is_authenticated
and user.is_approved
and user.allow_write
):
return True
# admins have permissions for everything
if (
user.is_authenticated
and user.is_admin
):
return True
# check upload permission
if permission.upper() == "UPLOAD":
if not has_permission("WRITE"):
return False
if app.config["ATTACHMENT_ACCESS"] == "ANONYMOUS":
return True
if (
app.config["ATTACHMENT_ACCESS"] == "REGISTERED"
and user.is_authenticated
):
return True
if (
app.config["ATTACHMENT_ACCESS"] == "APPROVED"
and user.is_authenticated
and user.is_approved
):
return True
if (
user.is_authenticated
and user.is_approved
and user.allow_upload
):
return True
# admins have permissions for everything
if (
user.is_authenticated
and user.is_admin
):
return True
if permission.upper() == "ADMIN":
if user.is_anonymous:
return False
return True == user.is_admin

return False
return auth_manager.has_permission(permission, user)


app.jinja_env.globals.update(has_permission=has_permission)

# these features help enable / disable the relevant parts of the UI
app.jinja_env.globals.update(
auth_supported_features=auth_manager.supported_features()
)

# vim: set et ts=8 sts=4 sw=4 ai:
20 changes: 14 additions & 6 deletions otterwiki/templates/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ <h2 class="card-title">User Management</h2>
{% for user in user_list %}
<tr>
<td>
{% if auth_supported_features['editing'] %}
<a href="{{ url_for("user", uid=user.id) }}">
<i class="fas fa-user-edit"></i>
</a>
{% endif %}
</td>
<td>
{{user.email}}
Expand All @@ -37,31 +39,36 @@ <h2 class="card-title">User Management</h2>
</td>
<td>
<div class="custom-checkbox">
<input type="checkbox" id="checkbox-a-{{user.id}}" name="is_approved" value="{{user.id}}" {{"checked=\"checked\"" if user.is_approved }}>
<input type="checkbox" id="checkbox-a-{{user.id}}" name="is_approved" value="{{user.id}}" {{"checked=\"checked\"" if
user.is_approved }} {% if not auth_supported_features['editing'] %}disabled{% endif %}>
<label for="checkbox-a-{{user.id}}"></label>
</div>
</td>
<td>
<div class="custom-checkbox">
<input type="checkbox" id="checkbox-b-{{user.id}}" name="allow_read" value="{{user.id}}" {{ "checked=\"checked\"" if user.allow_read }}>
<input type="checkbox" id="checkbox-b-{{user.id}}" name="allow_read" value="{{user.id}}" {{ "checked=\" checked\"" if
user.allow_read }} {% if not auth_supported_features['editing'] %}disabled{% endif %}>
<label for="checkbox-b-{{user.id}}"></label>
</div>
</td>
<td>
<div class="custom-checkbox">
<input type="checkbox" id="checkbox-c-{{user.id}}" name="allow_write" value="{{user.id}}" {{ "checked=\"checked\"" if user.allow_write }}>
<input type="checkbox" id="checkbox-c-{{user.id}}" name="allow_write" value="{{user.id}}" {{ "checked=\" checked\"" if
user.allow_write }} {% if not auth_supported_features['editing'] %}disabled{% endif %}>
<label for="checkbox-c-{{user.id}}"></label>
</div>
</td>
<td>
<div class="custom-checkbox">
<input type="checkbox" id="checkbox-d-{{user.id}}" name="allow_upload" value="{{user.id}}" {{ "checked=\"checked\"" if user.allow_upload }}>
<input type="checkbox" id="checkbox-d-{{user.id}}" name="allow_upload" value="{{user.id}}" {{ "checked=\" checked\"" if
user.allow_upload }} {% if not auth_supported_features['editing'] %}disabled{% endif %}>
<label for="checkbox-d-{{user.id}}"></label>
</div>
</td>
<td>
<div class="custom-checkbox">
<input type="checkbox" id="checkbox-e-{{user.id}}" name="is_admin" value="{{user.id}}" {{ "checked=\"checked\"" if user.is_admin }}>
<input type="checkbox" id="checkbox-e-{{user.id}}" name="is_admin" value="{{user.id}}" {{ "checked=\" checked\"" if
user.is_admin }} {% if not auth_supported_features['editing'] %}disabled{% endif %}>
<label for="checkbox-e-{{user.id}}"></label>
</div>
</td>
Expand All @@ -77,7 +84,8 @@ <h2 class="card-title">User Management</h2>
</em>
</div>
<div class="mt-10">
<input class="btn btn-primary" name="update_permissions" type="submit" value="Update Privileges">
<input class="btn btn-primary" name="update_permissions" type="submit" value="Update Privileges" {% if not
auth_supported_features['editing'] %}disabled{% endif %}>
</div>
</form>
</div>
Expand Down
Loading

0 comments on commit 3ed05d5

Please sign in to comment.