Remember consents

This commit is contained in:
Éloi Rivard 2020-09-17 10:00:39 +02:00
parent fd6fb648df
commit 00a0557f2e
7 changed files with 196 additions and 8 deletions

View file

@ -21,7 +21,7 @@ from flask_babel import Babel
from .flaskutils import current_user from .flaskutils import current_user
from .ldaputils import LDAPObjectHelper from .ldaputils import LDAPObjectHelper
from .oauth2utils import config_oauth from .oauth2utils import config_oauth
from .models import User, Token, AuthorizationCode, Client from .models import User, Token, AuthorizationCode, Client, Consent
try: # pragma: no cover try: # pragma: no cover
import sentry_sdk import sentry_sdk
@ -101,6 +101,7 @@ def setup_ldap_tree(app):
Token.initialize(conn) Token.initialize(conn)
AuthorizationCode.initialize(conn) AuthorizationCode.initialize(conn)
Client.initialize(conn) Client.initialize(conn)
Consent.initialize(conn)
conn.unbind_s() conn.unbind_s()

View file

@ -1,5 +1,6 @@
import ldap
import datetime import datetime
import ldap
import uuid
from authlib.common.encoding import json_loads, json_dumps from authlib.common.encoding import json_loads, json_dumps
from authlib.oauth2.rfc6749 import ( from authlib.oauth2.rfc6749 import (
ClientMixin, ClientMixin,
@ -222,3 +223,15 @@ class Token(LDAPObjectHelper, TokenMixin):
return False return False
return self.expire_date >= datetime.datetime.now() return self.expire_date >= datetime.datetime.now()
class Consent(LDAPObjectHelper):
objectClass = ["oauthConsent"]
base = "ou=consents,ou=oauth"
id = "cn"
def __init__(self, *args, **kwargs):
if "cn" not in kwargs:
kwargs["cn"] = str(uuid.uuid4())
super().__init__(*args, **kwargs)

View file

@ -3,7 +3,7 @@ from authlib.oauth2 import OAuth2Error
from flask import Blueprint, request, session, redirect from flask import Blueprint, request, session, redirect
from flask import render_template, jsonify, flash, current_app from flask import render_template, jsonify, flash, current_app
from flask_babel import gettext from flask_babel import gettext
from .models import User, Client from .models import User, Client, Consent
from .oauth2utils import authorization, IntrospectionEndpoint, RevocationEndpoint from .oauth2utils import authorization, IntrospectionEndpoint, RevocationEndpoint
from .forms import LoginForm from .forms import LoginForm
from .flaskutils import current_user from .flaskutils import current_user
@ -16,8 +16,14 @@ bp = Blueprint(__name__, "oauth")
def authorize(): def authorize():
user = current_user() user = current_user()
client = Client.get(request.values["client_id"]) client = Client.get(request.values["client_id"])
scopes = request.args.get("scope", "").split(" ")
# LOGIN
if not user: if not user:
if request.args.get("prompt") == "none":
return jsonify({"error": "login_required"})
form = LoginForm(request.form or None) form = LoginForm(request.form or None)
if request.method == "GET": if request.method == "GET":
return render_template("login.html", form=form, menu=False) return render_template("login.html", form=form, menu=False)
@ -30,12 +36,37 @@ def authorize():
return redirect(request.url) return redirect(request.url)
# CONSENT
consents = Consent.filter(
oauthClient=client.dn,
oauthSubject=user.dn,
)
consent = consents[0] if consents else None
if request.method == "GET": if request.method == "GET":
if consent and all(scope in set(consent.oauthScope) for scope in scopes):
return authorization.create_authorization_response(grant_user=user.dn)
elif request.args.get("prompt") == "none":
return jsonify({"error": "consent_required"})
try: try:
grant = authorization.validate_consent_request(end_user=user) grant = authorization.validate_consent_request(end_user=user)
except OAuth2Error as error: except OAuth2Error as error:
return jsonify(dict(error.get_body())) return jsonify(dict(error.get_body()))
if consent:
consent.oauthScope = list(set(scopes + consents[0].oauthScope))
else:
consent = Consent(
oauthClient=client.dn,
oauthSubject=user.dn,
oauthScope=scopes,
)
consent.save()
return render_template( return render_template(
"authorize.html", user=user, grant=grant, client=client, menu=False "authorize.html", user=user, grant=grant, client=client, menu=False
) )

View file

@ -353,3 +353,14 @@ olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.3 NAME 'oauthToken'
oauthTokenLifetime $ oauthTokenLifetime $
oauthRevoked ) oauthRevoked )
X-ORIGIN 'OAuth 2.0' ) X-ORIGIN 'OAuth 2.0' )
olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.4 NAME 'oauthConsent'
DESC 'OAuth 2.0 User consents'
SUP top
STRUCTURAL
MUST (
cn $
oauthSubject $
oauthClient $
oauthScope
)
X-ORIGIN 'OAuth 2.0' )

View file

@ -350,3 +350,14 @@ objectclass ( 1.3.6.1.4.1.56207.1.2.3 NAME 'oauthToken'
oauthTokenLifetime $ oauthTokenLifetime $
oauthRevoked ) oauthRevoked )
X-ORIGIN 'OAuth 2.0' ) X-ORIGIN 'OAuth 2.0' )
objectclass ( 1.3.6.1.4.1.56207.1.2.4 NAME 'oauthConsent'
DESC 'OAuth 2.0 User consents'
SUP top
STRUCTURAL
MUST (
cn $
oauthSubject $
oauthClient $
oauthScope
)
X-ORIGIN 'OAuth 2.0' )

View file

@ -9,7 +9,7 @@ from cryptography.hazmat.backends import default_backend as crypto_default_backe
from flask_webtest import TestApp from flask_webtest import TestApp
from werkzeug.security import gen_salt from werkzeug.security import gen_salt
from oidc_ldap_bridge import create_app from oidc_ldap_bridge import create_app
from oidc_ldap_bridge.models import User, Client, Token, AuthorizationCode from oidc_ldap_bridge.models import User, Client, Token, AuthorizationCode, Consent
from oidc_ldap_bridge.ldaputils import LDAPObjectHelper from oidc_ldap_bridge.ldaputils import LDAPObjectHelper
@ -253,3 +253,10 @@ def logged_admin(admin, testclient):
with testclient.session_transaction() as sess: with testclient.session_transaction() as sess:
sess["user_dn"] = admin.dn sess["user_dn"] = admin.dn
return admin return admin
@pytest.fixture(autouse=True)
def cleanups(slapd_connection):
yield
for consent in Consent.filter(conn=slapd_connection):
consent.delete(conn=slapd_connection)

View file

@ -1,7 +1,7 @@
from . import client_credentials from . import client_credentials
from authlib.oauth2.rfc7636 import create_s256_code_challenge from authlib.oauth2.rfc7636 import create_s256_code_challenge
from urllib.parse import urlsplit, parse_qs from urllib.parse import urlsplit, parse_qs
from oidc_ldap_bridge.models import AuthorizationCode, Token from oidc_ldap_bridge.models import AuthorizationCode, Token, Consent
from werkzeug.security import gen_salt from werkzeug.security import gen_salt
@ -76,9 +76,6 @@ def test_logout_login(testclient, slapd_connection, logged_user, client):
res = res.form.submit() res = res.form.submit()
assert 302 == res.status_code assert 302 == res.status_code
res = res.follow() res = res.follow()
assert 200 == res.status_code
res = res.forms["accept"].submit()
assert 302 == res.status_code assert 302 == res.status_code
assert res.location.startswith(client.oauthRedirectURIs[0]) assert res.location.startswith(client.oauthRedirectURIs[0])
@ -219,3 +216,120 @@ def test_code_challenge(testclient, slapd_connection, logged_user, client):
client.oauthTokenEndpointAuthMethod = "client_secret_basic" client.oauthTokenEndpointAuthMethod = "client_secret_basic"
client.save(slapd_connection) client.save(slapd_connection)
def test_authorization_code_flow_when_consent_already_given(
testclient, slapd_connection, logged_user, client
):
assert not Consent.filter(conn=slapd_connection)
res = testclient.get(
"/oauth/authorize",
params=dict(
response_type="code",
client_id=client.oauthClientID,
scope="profile",
nonce="somenonce",
),
)
assert 200 == res.status_code
res = res.forms["accept"].submit()
assert 302 == res.status_code
assert res.location.startswith(client.oauthRedirectURIs[0])
params = parse_qs(urlsplit(res.location).query)
code = params["code"][0]
authcode = AuthorizationCode.get(code, conn=slapd_connection)
assert authcode is not None
consents = Consent.filter(
oauthClient=client.dn, oauthSubject=logged_user.dn, conn=slapd_connection
)
assert "profile" in consents[0].oauthScope
res = testclient.post(
"/oauth/token",
params=dict(
grant_type="authorization_code",
code=code,
scope="profile",
redirect_uri=client.oauthRedirectURIs[0],
),
headers={"Authorization": f"Basic {client_credentials(client)}"},
)
assert 200 == res.status_code
assert "access_token" in res.json
res = testclient.get(
"/oauth/authorize",
params=dict(
response_type="code",
client_id=client.oauthClientID,
scope="profile",
nonce="somenonce",
),
)
assert 302 == res.status_code
assert res.location.startswith(client.oauthRedirectURIs[0])
params = parse_qs(urlsplit(res.location).query)
assert "code" in params
def test_prompt_none(testclient, slapd_connection, logged_user, client):
Consent(
oauthClient=client.dn,
oauthSubject=logged_user.dn,
oauthScope=["openid", "profile"],
).save(conn=slapd_connection)
res = testclient.get(
"/oauth/authorize",
params=dict(
response_type="code",
client_id=client.oauthClientID,
scope="profile",
nonce="somenonce",
prompt="none",
),
)
assert 302 == res.status_code
assert res.location.startswith(client.oauthRedirectURIs[0])
params = parse_qs(urlsplit(res.location).query)
assert "code" in params
def test_prompt_not_logged(testclient, slapd_connection, user, client):
Consent(
oauthClient=client.dn,
oauthSubject=user.dn,
oauthScope=["openid", "profile"],
).save(conn=slapd_connection)
res = testclient.get(
"/oauth/authorize",
params=dict(
response_type="code",
client_id=client.oauthClientID,
scope="profile",
nonce="somenonce",
prompt="none",
),
)
assert 200 == res.status_code
assert "login_required" == res.json.get("error")
def test_prompt_no_consent(testclient, slapd_connection, logged_user, client):
res = testclient.get(
"/oauth/authorize",
params=dict(
response_type="code",
client_id=client.oauthClientID,
scope="profile",
nonce="somenonce",
prompt="none",
),
)
assert 200 == res.status_code
assert "consent_required" == res.json.get("error")