From fcce48c08549ba5c832d9e333675bc6f92f87b64 Mon Sep 17 00:00:00 2001 From: Hugo Rodrigues Date: Tue, 13 Jul 2021 19:27:04 +0100 Subject: [PATCH] Added LDAP authentication If the builtin authentication fails, checks the username and password against a LDAP server Allow to create user without password since we don't store the user password inside the database. Every function that has the middleware.authengine decorator will be used as authentication engine. The order of the engines is based on the value of sequence, lower sequences go first --- wg-manager-backend/const.py | 30 ++++++ wg-manager-backend/db/user.py | 4 +- wg-manager-backend/middleware.py | 131 ++++++++++++++++++++++++++ wg-manager-backend/requirements.txt | 3 +- wg-manager-backend/routers/v1/user.py | 11 +-- 5 files changed, 168 insertions(+), 11 deletions(-) diff --git a/wg-manager-backend/const.py b/wg-manager-backend/const.py index 06126c5..ab3a0bf 100644 --- a/wg-manager-backend/const.py +++ b/wg-manager-backend/const.py @@ -35,6 +35,36 @@ SERVER = os.getenv("SERVER", "1") == "1" CLIENT = os.getenv("CLIENT", "0") == "1" CLIENT_START_AUTOMATICALLY = os.getenv("CLIENT_START_AUTOMATICALLY", "1") == "1" +AUTH_LOCAL_ENABLED = os.getenv("AUTH_LOCAL_ENABLED", "1") == "1" +AUTH_LDAP_ENABLED = os.getenv("AUTH_LDAP_ENABLED", "0") == "1" +AUTH_LDAP_SERVER = os.getenv("AUTH_LDAP_SERVER", None) +AUTH_LDAP_PORT = os.getenv("AUTH_LDAP_PORT", None) +AUTH_LDAP_USER = os.getenv("AUTH_LDAP_USER", None) +AUTH_LDAP_PASSWORD = os.getenv("AUTH_LDAP_PASSWORD", None) +AUTH_LDAP_BASE = os.getenv("AUTH_LDAP_BASE", None) +AUTH_LDAP_FILTER = os.getenv("AUTH_LDAP_FILTER", None) +AUTH_LDAP_ACTIVEDIRECTORY = os.getenv("AUTH_LDAP_ACTIVEDIRECTORY", "0") == "1" +AUTH_LDAP_DOMAIN = os.getenv("AUTH_LDAP_DOMAIN", None) +AUTH_LDAP_SECURITY = os.getenv("AUTH_LDAP_SECURITY", None) +AUTH_LDAP_SECURITY_VALID_CERTIFICATE = os.getenv("AUTH_LDAP_SECURITY_VALID_CERTIFICATE", "1") == "1" + +assert AUTH_LOCAL_ENABLED or AUTH_LDAP_ENABLED, "At least one authentication engine must be enabled" + +if AUTH_LDAP_ENABLED: + assert AUTH_LDAP_SERVER, "AUTH_LDAP_SERVER is required" + assert AUTH_LDAP_SECURITY in (None, "SSL", "TLS"), "Invalid value for AUTH_LDAP_SECURITY. Valid values are SSL and TLS" + assert AUTH_LDAP_BASE, "AUTH_LDAP_BASE is required" + assert AUTH_LDAP_FILTER, "AUTH_LDAP_FILTER is required" + if AUTH_LDAP_ACTIVEDIRECTORY: + assert AUTH_LDAP_DOMAIN, "AUTH_LDAP_DOMAIN is required when using Active Directory" + if not AUTH_LDAP_PORT: + if AUTH_LDAP_SECURITY == "SSL": + AUTH_LDAP_PORT = 636 + else: + AUTH_LDAP_PORT = 389 + else: + AUTH_LDAP_PORT = int(AUTH_LDAP_PORT) + if not IS_DOCKER: CMD_WG_COMMAND = ["sudo"] + CMD_WG_COMMAND CMD_WG_QUICK = ["sudo"] + CMD_WG_QUICK diff --git a/wg-manager-backend/db/user.py b/wg-manager-backend/db/user.py index 17fc7c6..c356d70 100644 --- a/wg-manager-backend/db/user.py +++ b/wg-manager-backend/db/user.py @@ -33,7 +33,9 @@ def get_user_by_username_and_password(db: Session, username: str, password: str) def create_user(sess: Session, user: models.User): - user.password = get_password_hash(user.password) + # Only hash password if set. Use case: LDAP users don't have passwords on database + if user.password: + user.password = get_password_hash(user.password) sess.add(user) sess.commit() diff --git a/wg-manager-backend/middleware.py b/wg-manager-backend/middleware.py index ce13ca5..ccde818 100644 --- a/wg-manager-backend/middleware.py +++ b/wg-manager-backend/middleware.py @@ -1,4 +1,5 @@ from datetime import timedelta, datetime +import ssl import jwt from fastapi import Depends, HTTPException @@ -11,11 +12,24 @@ from starlette import status from starlette.requests import Request from starlette.responses import Response +import ldap3 + +import db import const import schemas from database import models from database.database import SessionLocal +if const.AUTH_LDAP_ENABLED: + if const.AUTH_LDAP_SECURITY: + ldap_tls_config=ldap3.Tls(validate=ssl.CERT_REQUIRED if const.AUTH_LDAP_SECURITY_VALID_CERTIFICATE else ssl.CERT_NONE) + else: + ldap_tls_config = False + LDAP_SERVER = ldap3.Server(const.AUTH_LDAP_SERVER, const.AUTH_LDAP_PORT, get_info=ldap3.ALL, use_ssl=const.AUTH_LDAP_SECURITY=="SSL", tls=ldap_tls_config) +else: + LDAP_SERVER = None + + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login", auto_error=False) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -100,3 +114,120 @@ def auth(token: str = Depends(oauth2_scheme), api_key: str = Depends(retrieve_ap raise credentials_exception return user +AUTH_ENGINES: dict = {} + +def authengine(name: str, sequence: int, enabled: bool): + def decorator(f): + AUTH_ENGINES[name] = { + "function": f, + "sequence": sequence, + "enabled": enabled + } + + return decorator + +class Authentication(object): + + def __init__(self, username: str, password: str, sess: Session): + self.username = username + self.password = password + self.sess = sess + + def login(self): + user: schemas.UserInDB = False + + for engine in sorted(AUTH_ENGINES.keys(), key=lambda x: AUTH_ENGINES[x]["sequence"]): + if not AUTH_ENGINES[engine]["enabled"]: + continue + try: + user = AUTH_ENGINES[engine]["function"](self) + logger.info("User %s logged in via the %s authentication engine" % (self.username, engine)) + break + except Exception as err: + logger.warning("Login failed for %s using the %s authentication engine: %s" % (self.username, engine, err)) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + + @authengine(name="builtin", sequence=10, enabled=const.AUTH_LOCAL_ENABLED) + def _builtin(self): + assert const.AUTH_LOCAL_ENABLED, "LOCAL authentication not enabled" + user: schemas.UserInDB = schemas.UserInDB(username=self.username, password="").from_db(self.sess) + + # Verify password + assert user and verify_password(self.password, user.password), "Invalid username or password" + + return user + + @authengine(name="LDAP", sequence=20, enabled=const.AUTH_LDAP_ENABLED) + def _ldap(self): + assert const.AUTH_LDAP_ENABLED, "LDAP authentication not enabled" + + def _get_ldap_attr(ldapobj, attribute): + attr = ldapobj["attributes"].get(attribute, None) + if isinstance(attr, list): + try: + return attr[0] + except IndexError: + return None + return attr + + ldap_auth = ldap3.ANONYMOUS + ldap_user = None + valid: bool = False + if const.AUTH_LDAP_USER: + if const.AUTH_LDAP_ACTIVEDIRECTORY: + ldap_auth = ldap3.NTLM + else: + ldap_auth = ldap3.SIMPLE + + # Connect with binddn, if set, to search the user + with ldap3.Connection(LDAP_SERVER, user=const.AUTH_LDAP_USER, password=const.AUTH_LDAP_PASSWORD, authentication=ldap_auth, read_only=True, auto_bind=ldap3.AUTO_BIND_NONE) as cn: + if const.AUTH_LDAP_SECURITY == "TLS": + cn.start_tls() + try: + assert cn.bind() + logger.debug("LDAP system bind complete") + except: + logger.exception("Unable to connect/bind to LDAP server") + raise + # TODO find a parsing tool like python-ldap.filter.filter_format + ldap_filter: str = const.AUTH_LDAP_FILTER % self.username + ldap_attributes: list = ["cn", "mail"] + + if const.AUTH_LDAP_ACTIVEDIRECTORY: + ldap_attributes.extend(["samAccountName", "givenName"]) + cn.search(search_base=const.AUTH_LDAP_BASE, search_filter=ldap_filter, attributes=ldap_attributes) + assert len(cn.response) == 1, "Found %d LDAP users for the filter %s" % (len(cn.response), ldap_filter) + ldap_user = cn.response[0].copy() + + logininfo: str = "%s\%s" % (const.AUTH_LDAP_DOMAIN, _get_ldap_attr(ldap_user, "samAccountName")) if const.AUTH_LDAP_ACTIVEDIRECTORY else ldap_user["dn"] + with ldap3.Connection(LDAP_SERVER, user=logininfo, password=self.password, authentication=ldap3.NTLM if const.AUTH_LDAP_ACTIVEDIRECTORY else ldap3.SIMPLE, read_only=True, auto_bind=ldap3.AUTO_BIND_NONE) as cn: + if const.AUTH_LDAP_SECURITY == "TLS": + cn.start_tls() + assert cn.bind(), "LDAP authentication failed for %s" % self.username + cn.unbind() + + user: schema.UserInDB = schemas.UserInDB(username=self.username, password="").from_db(self.sess) + if user: + user.full_name = _get_ldap_attr(ldap_user, "givenName" if const.AUTH_LDAP_ACTIVEDIRECTORY else "cn") + user.email = _get_ldap_attr(ldap_user, "mail") + user.password = None + db.user.update_user(self.sess, user) + else: + if not db.user.create_user(self.sess, models.User( + username=username, + password=None, + full_name=_get_ldap_attr(ldap_user, "givenName" if const.AUTH_LDAP_ACTIVEDIRECTORY else "cn"), + email=_get_ldap_attr(ldap_user, "mail"), + role="user", # TODO: Map LDAP groups to roles + )): + raise HTTPException(status_code=400, detail="Could not create LDAP user") + user: schema.UserInDB = schemas.UserInDB(username=self.username, password="").from_db(self.sess) + return user diff --git a/wg-manager-backend/requirements.txt b/wg-manager-backend/requirements.txt index 970c54a..95fd9a5 100644 --- a/wg-manager-backend/requirements.txt +++ b/wg-manager-backend/requirements.txt @@ -18,4 +18,5 @@ uvloop httptools qrcode[pil] alembic -loguru \ No newline at end of file +loguru +ldap3==2.9 diff --git a/wg-manager-backend/routers/v1/user.py b/wg-manager-backend/routers/v1/user.py index 6a21f54..30659ef 100644 --- a/wg-manager-backend/routers/v1/user.py +++ b/wg-manager-backend/routers/v1/user.py @@ -78,15 +78,8 @@ def get_api_keys( @router.post("/login", response_model=schemas.Token) def login(*, username: str = Form(...), password: str = Form(...), sess: Session = Depends(middleware.get_db)): - user: schemas.UserInDB = schemas.UserInDB(username=username, password="").from_db(sess) - - # Verify password - if not user or not middleware.verify_password(password, user.password): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, - ) + auth: middleware.Authentication = middleware.Authentication(username, password, sess) + user: schemas.UserInDB = auth.login() # Create token access_token_expires = timedelta(minutes=const.ACCESS_TOKEN_EXPIRE_MINUTES)