diff --git a/.dockerignore b/.dockerignore new file mode 120000 index 0000000..3e4e48b --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5d620ed..bd3bfe1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -.ropeproject -*.py[co] -.env +**/.ropeproject/* +**/*.py[co] +**/.env diff --git a/docker-compose.yml b/docker-compose.yml index ef2c1bc..a249133 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: backend: build: . + image: registry.k0.hswaw.net/informatic/sso-v2 ports: - 5000:5000 volumes: @@ -18,6 +19,7 @@ services: - TEMPLATES_AUTO_RELOAD=true - LDAP_BIND_DN - LDAP_BIND_PASSWORD + - LOGGING_LEVEL=DEBUG volumes: pgdata: diff --git a/requirements.txt b/requirements.txt index f69d07a..e4cf8cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ python-ldap==3.2.0 requests==2.23.0 six==1.15.0 SQLAlchemy==1.3.17 +sqlalchemy-cockroachdb==0.4.0 urllib3==1.25.9 Werkzeug==1.0.1 WTForms==2.3.1 diff --git a/sso/__init__.py b/sso/__init__.py index 8e2cef0..78ba4fb 100644 --- a/sso/__init__.py +++ b/sso/__init__.py @@ -1,6 +1,7 @@ import flask -from sso.extensions import db, migrate, login_manager +from sso.extensions import db, migrate, login_manager, csrf from sso.oauth2 import config_oauth +import logging def create_app(): @@ -14,6 +15,7 @@ def create_app(): db.init_app(app) migrate.init_app(app, db) login_manager.init_app(app) + csrf.init_app(app) config_oauth(app) import sso.views @@ -31,4 +33,7 @@ def create_app(): app.config.get("PROXYFIX_NUM_PROXIES"), ) + if app.config.get('LOGGING_LEVEL'): + logging.basicConfig(level=app.config['LOGGING_LEVEL']) + return app diff --git a/sso/extensions.py b/sso/extensions.py index a3be2f3..3524110 100644 --- a/sso/extensions.py +++ b/sso/extensions.py @@ -1,9 +1,11 @@ import flask_sqlalchemy import flask_migrate import flask_login +from flask_wtf.csrf import CSRFProtect db = flask_sqlalchemy.SQLAlchemy() migrate = flask_migrate.Migrate() login_manager = flask_login.LoginManager() login_manager.login_view = "/login" +csrf = CSRFProtect() diff --git a/sso/forms.py b/sso/forms.py index 555b0e3..42f71a8 100644 --- a/sso/forms.py +++ b/sso/forms.py @@ -50,7 +50,7 @@ class ClientForm(FlaskForm): token_endpoint_auth_method = SelectField( "Token endpoint authentication method", - choices=[("client_secret_basic", "Basic"), ("client_secret_post", "POST")], + choices=[("client_secret_basic", "Basic"), ("client_secret_post", "POST"), ("client_secret_get", "Query args (DEPRECATED)")], validators=[DataRequired()], ) diff --git a/sso/oauth2.py b/sso/oauth2.py index 8323213..cfad0c8 100644 --- a/sso/oauth2.py +++ b/sso/oauth2.py @@ -4,6 +4,7 @@ from authlib.integrations.sqla_oauth2 import ( create_save_token_func, create_bearer_token_validator, ) +from authlib.oauth2.rfc6749.errors import InvalidClientError from authlib.oauth2.rfc6749.grants import ( AuthorizationCodeGrant as _AuthorizationCodeGrant, ) @@ -13,11 +14,15 @@ from authlib.oidc.core.grants import ( OpenIDHybridGrant as _OpenIDHybridGrant, ) from authlib.oidc.core import UserInfo +from authlib.common.urls import urlparse, url_decode from werkzeug.security import gen_salt from .extensions import db from .models import Client, AuthorizationCode, Token from .directory import LDAPUserProxy from flask import current_app as app +import logging + +log = logging.getLogger(__name__) DUMMY_JWT_CONFIG = { @@ -110,8 +115,34 @@ class HybridGrant(_OpenIDHybridGrant): return generate_user_info(user, scope) -authorization = AuthorizationServer() -require_oauth = ResourceProtector() +def _validate_client(query_client, client_id, state=None, status_code=400): + if client_id is None: + raise InvalidClientError(state=state, status_code=status_code) + + client = query_client(client_id) + if not client: + raise InvalidClientError(state=state, status_code=status_code) + + return client + +def authenticate_client_secret_get(query_client, request): + """Authenticates clients providing their secret via query args (either via GET or POST) request""" + data = request.args + client_id = data.get('client_id') + client_secret = data.get('client_secret') + if client_id and client_secret: + client = _validate_client(query_client, client_id, request.state) + if client.check_token_endpoint_auth_method('client_secret_get') \ + and client.check_client_secret(client_secret): + log.debug( + 'Authenticate %s via "client_secret_get" ' + 'success', client_id + ) + return client + log.debug( + 'Authenticate %s via "client_secret_get" ' + 'failed', client_id + ) def save_token(token, request): @@ -134,13 +165,42 @@ def save_token(token, request): db.session.commit() +class CustomAuthorizationCodeGrant(AuthorizationCodeGrant): + # kill me (inventory) + TOKEN_ENDPOINT_HTTP_METHODS = ['GET', 'POST'] + TOKEN_ENDPOINT_AUTH_METHODS = [ + 'client_secret_basic', 'client_secret_post', 'client_secret_get', 'none' + ] + + def validate_token_request(self): + # TODO apply this hack only on client_secret_get authentication method + self.request.form = self.request.data + + return super(CustomAuthorizationCodeGrant, self).validate_token_request() + +class CustomResourceProtector(ResourceProtector): + def validate_request(self, scope, request, scope_operator='AND'): + # damn you gerrit + args = dict(url_decode(urlparse.urlparse(request.uri).query)) + if args.get('access_token'): + token_string = args.get('access_token') + return self._token_validators['bearer'](token_string, scope, request, scope_operator) + + return super(CustomResourceProtector, self).validate_request(scope, request, scope_operator) + + +authorization = AuthorizationServer() +require_oauth = CustomResourceProtector() + + def config_oauth(app): query_client = create_query_client_func(db.session, Client) authorization.init_app(app, query_client=query_client, save_token=save_token) + authorization.register_client_auth_method('client_secret_get', authenticate_client_secret_get) # support all openid grants authorization.register_grant( - AuthorizationCodeGrant, [OpenIDCode(require_nonce=False)] + CustomAuthorizationCodeGrant, [OpenIDCode(require_nonce=False)] ) authorization.register_grant(ImplicitGrant) authorization.register_grant(HybridGrant) diff --git a/sso/settings.py b/sso/settings.py index 71bcb8b..6f7294f 100644 --- a/sso/settings.py +++ b/sso/settings.py @@ -3,6 +3,9 @@ from environs import Env env = Env() env.read_env() +SQLALCHEMY_TRACK_MODIFICATIONS = False +WTF_CSRF_CHECK_DEFAULT = False + SECRET_KEY = env.str("SECRET_KEY", default="randomstring") db_username = env.str("DATABASE_USERNAME", default="postgres") @@ -33,7 +36,6 @@ LDAP_BIND_DN = env.str( "LDAP_BIND_DN", default="cn=auth,ou=Services,dc=hackerspace,dc=pl" ) LDAP_BIND_PASSWORD = env.str("LDAP_BIND_PASSWORD", default="insert password here") -SQLALCHEMY_TRACK_MODIFICATIONS = False PROXYFIX_ENABLE = env.bool('PROXYFIX_ENABLE', default=True) PROXYFIX_NUM_PROXIES = env.int('PROXYFIX_NUM_PROXIES', default=1) @@ -44,3 +46,5 @@ JWT_CONFIG = { "iss": "https://sso.hackerspace.pl", "exp": 3600, } + +LOGGING_LEVEL = env.str('LOGGING_LEVEL', default=None) diff --git a/sso/views.py b/sso/views.py index e4c1b59..a67129c 100644 --- a/sso/views.py +++ b/sso/views.py @@ -11,6 +11,7 @@ from flask import ( ) import uuid from flask_login import login_required, current_user, login_user, logout_user +from sso.extensions import csrf from sso.directory import LDAPUserProxy, check_credentials from sso.models import db, Token, Client from sso.forms import LoginForm, ClientForm @@ -38,6 +39,8 @@ def profile(): @bp.route("/token//revoke", methods=["POST"]) @login_required def token_revoke(id): + csrf.protect() + token = Token.query.filter( Token.user_id == current_user.username, Token.id == id ).first() @@ -109,8 +112,6 @@ def client_edit(client_id): # OAuth API - - @bp.route("/oauth/authorize", methods=["GET", "POST"]) @login_required def authorize(): @@ -128,9 +129,12 @@ def authorize(): return authorization.create_authorization_response(grant_user=current_user) return render_template( - "oauthorize.html", user=current_user, grant=grant, client=grant.client + "oauthorize.html", user=current_user, grant=grant, client=grant.client, + scopes=grant.request.scope.split() ) + csrf.protect() + if request.form["confirm"]: grant_user = current_user else: @@ -139,7 +143,7 @@ def authorize(): return authorization.create_authorization_response(grant_user=grant_user) -@bp.route("/oauth/token", methods=["POST"]) +@bp.route("/oauth/token", methods=["GET", "POST"]) def issue_token(): return authorization.create_token_response() @@ -147,7 +151,7 @@ def issue_token(): # HSWAW specific endpoint @bp.route("/api/profile") @bp.route("/api/1/profile") -@require_oauth("profile:read") +@require_oauth("profile:read openid", "OR") def api_profile(): user = current_token.user return jsonify( @@ -161,8 +165,7 @@ def api_profile(): # OpenIDConnect userinfo @bp.route("/api/1/userinfo") -# @require_oauth("profile:read") -@require_oauth("openid") +@require_oauth("profile:read openid", "OR") def api_userinfo(): user = current_token.user # user = LDAPUserProxy(flask.request.oauth.user) diff --git a/templates/_helpers.html b/templates/_helpers.html index 2c4a648..02a94c4 100644 --- a/templates/_helpers.html +++ b/templates/_helpers.html @@ -103,3 +103,7 @@ None {% endif %} {% endmacro %} + +{% macro csrf_field() %} + +{% endmacro %} diff --git a/templates/oauthorize.html b/templates/oauthorize.html index 4bf8c79..5798737 100644 --- a/templates/oauthorize.html +++ b/templates/oauthorize.html @@ -1,14 +1,15 @@ {% extends "base.html" %} +{% from "_helpers.html" import csrf_field %} {% block content %}
-

{{ client.name }} +

{{ client.client_name }} {% if client.approved %}{% endif %}

This app would like to:

- {{ user }}

On your ({{user.username}}) behalf.

- {#{{ form.csrf_token }}#} - + {{ csrf_field() }}