Some authorization_code work

This commit is contained in:
Éloi Rivard 2020-08-17 17:49:49 +02:00
parent d75fcb163b
commit ea98ca6702
10 changed files with 200 additions and 94 deletions

View file

@ -25,6 +25,7 @@ olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.3 NAME 'oauthRedirectURI'
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.4 NAME 'oauthResponseType'
@ -49,6 +50,7 @@ olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.6 NAME 'oauthNonce'
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.7 NAME 'oauthAuthorizationDate'
@ -251,6 +253,31 @@ olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.29 NAME 'oauthTokenEndpointAuthMethod
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0 Dynamic Client Registration Protocol' )
olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.30 NAME 'oauthSubject'
DESC 'OAuth 2.0 Token subject'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0 Dynamic Client Registration Protocol' )
olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.31 NAME 'oauthRedirectURIs'
DESC 'Authorization Code Redirection URI'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.32 NAME 'oauthAuthorizationLifetime'
DESC 'OAuth 2.0 authorization code lifetime, in seconds'
EQUALITY integerMatch
ORDERING integerOrderingMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient'
DESC 'OAuth 2.0 Authorization Code'
SUP top
@ -260,7 +287,7 @@ olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient'
oauthClientName $
oauthClientContact $
oauthClientURI $
oauthRedirectURI $
oauthRedirectURIs $
oauthLogoURI $
oauthIssueDate $
oauthClientSecret $
@ -283,13 +310,14 @@ olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.2 NAME 'oauthAuthorizationCode'
STRUCTURAL
MUST oauthCode
MAY ( description $
oauthCode $
oauthClientID $
oauthSubject $
oauthRedirectURI $
oauthResponseType $
oauthScope $
oauthNonce $
oauthAuthorizationDate $
oauthAuthorizationLifetime $
oauthCodeChallenge $
oauthCodeChallengeMethod )
X-ORIGIN 'OAuth 2.0' )
@ -300,6 +328,7 @@ olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.3 NAME 'oauthToken'
MUST oauthAccessToken
MAY ( description $
oauthClientID $
oauthSubject $
oauthTokenType $
oauthRefreshToken $
oauthScope $

View file

@ -1,7 +1,7 @@
import ldap
import os
import toml
from . import routes, clients
from . import routes, clients, oauth
from flask import Flask, g, request
from flask_babel import Babel
@ -39,6 +39,7 @@ def setup_app(app):
config_oauth(app)
app.register_blueprint(routes.bp)
app.register_blueprint(oauth.bp, url_prefix="/oauth")
app.register_blueprint(clients.bp, url_prefix="/client")
babel = Babel(app)

View file

@ -33,7 +33,7 @@ class ClientAdd(FlaskForm):
validators=[wtforms.validators.DataRequired()],
render_kw={"placeholder": "https://mydomain.tld"},
)
oauthRedirectURI = wtforms.fields.html5.URLField(
oauthRedirectURIs = wtforms.fields.html5.URLField(
gettext("Redirect URIs"),
validators=[wtforms.validators.DataRequired()],
render_kw={"placeholder": "https://mydomain.tld/callback"},
@ -129,7 +129,7 @@ def add():
oauthClientContact=form["oauthClientContact"].data,
oauthClientURI=form["oauthClientURI"].data,
oauthGrantType=form["oauthGrantType"].data,
oauthRedirectURI=[form["oauthRedirectURI"].data],
oauthRedirectURIs=[form["oauthRedirectURIs"].data],
oauthResponseType=form["oauthResponseType"].data,
oauthScope=form["oauthScope"].data.split(" "),
oauthTokenEndpointAuthMethod=form["oauthTokenEndpointAuthMethod"].data,
@ -157,7 +157,7 @@ def edit(client_id):
client = Client.get(client_id)
data = dict(client)
data["oauthScope"] = " ".join(data["oauthScope"])
data["oauthRedirectURI"] = data["oauthRedirectURI"][0]
data["oauthRedirectURIs"] = data["oauthRedirectURIs"][0]
form = ClientAdd(request.form or None, data=data, client=client)
if not request.form:
@ -175,7 +175,7 @@ def edit(client_id):
oauthClientContact=form["oauthClientContact"].data,
oauthClientURI=form["oauthClientURI"].data,
oauthGrantType=form["oauthGrantType"].data,
oauthRedirectURI=[form["oauthRedirectURI"].data],
oauthRedirectURIs=[form["oauthRedirectURIs"].data],
oauthResponseType=form["oauthResponseType"].data,
oauthScope=form["oauthScope"].data.split(" "),
oauthTokenEndpointAuthMethod=form["oauthTokenEndpointAuthMethod"].data,

View file

@ -24,6 +24,13 @@ class LDAPObjectHelper:
self.may.extend(oc.may)
self.must.extend(oc.must)
def __repr__(self):
return "<{} {}={}>".format(
self.__class__.__name__,
self.id,
getattr(self, self.id)
)
def keys(self):
return self.must + self.may

View file

@ -14,12 +14,13 @@ class User(LDAPObjectHelper):
base = "ou=users,dc=mydomain,dc=tld"
id = "cn"
def __repr__(self):
return self.cn[0]
def check_password(self, password):
return password == "valid"
@property
def name(self):
return self.cn[0]
class Client(LDAPObjectHelper, ClientMixin):
objectClass = ["oauthClient"]
@ -34,13 +35,13 @@ class Client(LDAPObjectHelper, ClientMixin):
return self.oauthClientID
def get_default_redirect_uri(self):
return self.oauthRedirectURI
return self.oauthRedirectURIs[0]
def get_allowed_scope(self, scope):
return self.oauthScope
def check_redirect_uri(self, redirect_uri):
return redirect_uri in self.oauthRedirectURI
return redirect_uri in self.oauthRedirectURIs
def has_client_secret(self):
return bool(self.oauthClientSecret)
@ -96,7 +97,7 @@ class AuthorizationCode(LDAPObjectHelper, AuthorizationCodeMixin):
id = "oauthCode"
def get_redirect_uri(self):
return Client.get(self.authzClientID).oauthRedirectURI
return self.oauthRedirectURI
def get_scope(self):
return self.oauth2ScopeValue
@ -108,13 +109,13 @@ class AuthorizationCode(LDAPObjectHelper, AuthorizationCodeMixin):
return expires_at >= time.time()
def get_client_id(self):
return self.client_id
return self.oauthClientID
def get_expires_in(self):
return self.expires_in
return self.oauthAuthorizationLifetime
def get_expires_at(self):
return self.issued_at + self.expires_in
return datetime.datetime.strptime(self.oauthAuthorizationDate, "%Y%m%d%H%M%SZ") + datetime.timedelta(seconds=int(self.oauthAuthorizationLifetime))
class Token(LDAPObjectHelper, TokenMixin):

71
web/oauth.py Normal file
View file

@ -0,0 +1,71 @@
import wtforms
from authlib.oauth2 import OAuth2Error
from flask import Blueprint, request, session, redirect
from flask import render_template, jsonify, flash
from flask_babel import gettext
from flask_wtf import FlaskForm
from .models import User, Client
from .oauth2utils import authorization
bp = Blueprint(__name__, "oauth")
class LoginForm(FlaskForm):
login = wtforms.StringField(
gettext("Username"),
validators=[wtforms.validators.DataRequired()],
render_kw={"placeholder": "mdupont"},
)
password = wtforms.PasswordField(
gettext("Password"), validators=[wtforms.validators.DataRequired()]
)
@bp.route("/authorize", methods=["GET", "POST"])
def authorize():
user = User.get(session["user_dn"]) if "user_dn" in session else None
client = Client.get(request.values["client_id"])
if not user:
form = LoginForm(request.form or None)
if request.method == "GET":
return render_template("login.html", form=form)
if not form.validate():
flash(gettext("Login failed, please check your information"), "error")
return render_template("login.html", form=form)
user = User.get(form.login.data)
if not user or not user.check_password(form.password.data):
flash(gettext("Login failed, please check your information"), "error")
return render_template("login.html", form=form)
session["user_dn"] = form.login.data
return redirect(request.url)
if request.method == "GET":
try:
grant = authorization.validate_consent_request(end_user=user)
except OAuth2Error as error:
return jsonify(dict(error.get_body()))
return render_template("authorize.html", user=user, grant=grant, client=client)
if request.form["answer"] == "logout":
del session["user_dn"]
flash(gettext("You have been successfully logged out."), "success")
return redirect(request.url)
if request.form["answer"] == "deny":
grant_user = None
if request.form["answer"] == "accept":
grant_user = user.dn
return authorization.create_authorization_response(grant_user=grant_user)
@bp.route("/token", methods=["POST"])
def issue_token():
return authorization.create_token_response()

View file

@ -35,18 +35,20 @@ def generate_user_info(user, scope):
def create_authorization_code(client, grant_user, request):
raise NotImplementedError()
code = gen_salt(48)
nonce = request.data.get("nonce")
item = AuthorizationCode(
code=code,
client_id=client.client_id,
redirect_uri=request.redirect_uri,
scope=request.scope,
user_id=grant_user.id,
nonce=nonce,
now = datetime.datetime.now()
code = AuthorizationCode(
oauthCode=gen_salt(48),
oauthSubject=grant_user,
oauthClientID=client.oauthClientID,
oauthRedirectURI=request.redirect_uri or client.oauthRedirectURIs[0],
oauthScope=request.scope,
oauthNonce=nonce or "nonce", #TODO
oauthAuthorizationDate=now.strftime("%Y%m%d%H%M%SZ"),
oauthAuthorizationLifetime=str(84000),
)
return code
code.save()
return code.oauthCode
class AuthorizationCodeGrant(_AuthorizationCodeGrant):
@ -54,11 +56,11 @@ class AuthorizationCodeGrant(_AuthorizationCodeGrant):
return create_authorization_code(client, grant_user, request)
def parse_authorization_code(self, code, client):
item = AuthorizationCode.query.filter_by(
code=code, client_id=client.client_id
).first()
if item and not item.is_expired():
return item
item = AuthorizationCode.filter(
oauthCode=code, oauthClientID=client.oauthClientID
)
if item and not item[0].get_expires_at() < datetime.datetime.now():
return item[0]
def delete_authorization_code(self, authorization_code):
raise NotImplementedError()

View file

@ -1,19 +1,12 @@
from flask import Blueprint, request, session
from flask import render_template, redirect, jsonify
from authlib.oauth2 import OAuth2Error
from .models import User, Client
from .oauth2utils import authorization, require_oauth
from .oauth2utils import require_oauth
bp = Blueprint(__name__, "home")
def current_user():
if "user_dn" in session:
return User.get(session["user_dn"])
return None
@bp.route("/", methods=("GET", "POST"))
def home():
if request.method == "POST":
@ -27,35 +20,8 @@ def home():
session["user_dn"] = user.dn
return redirect("/")
user = current_user()
if user:
clients = Client.filter()
else:
clients = []
return render_template("home.html", user=user, clients=clients)
@bp.route("/oauth/authorize", methods=["GET", "POST"])
def authorize():
user = current_user()
if request.method == "GET":
try:
grant = authorization.validate_consent_request(end_user=user)
except OAuth2Error as error:
return jsonify(dict(error.get_body()))
return render_template("authorize.html", user=user, grant=grant)
if not user and "username" in request.form:
username = request.form.get("username")
user = User.get(username)
if request.form["confirm"]:
grant_user = user
else:
grant_user = None
return authorization.create_authorization_response(grant_user=grant_user)
return render_template("home.html", clients=clients)
@bp.route("/logout")
@ -64,11 +30,6 @@ def logout():
return redirect("/")
@bp.route("/oauth/token", methods=["POST"])
def issue_token():
return authorization.create_token_response()
@bp.route("/api/me")
@require_oauth("profile")
def api_me():

View file

@ -1,28 +1,33 @@
{% extends 'base.html' %}
{% block content %}
<div class="ui segment">
<p>The application <strong>{{grant.client.client_name}}</strong> is requesting:
<strong>{{ grant.request.scope }}</strong>
</p>
<p>
from You - a.k.a. <strong>{{ user.username }}</strong>
</p>
<form action="" method="post">
<label>
<input type="checkbox" name="confirm">
<span>Consent?</span>
</label>
{% if not user %}
<p>You haven't logged in. Log in with:</p>
<div>
<input type="text" name="username">
</div>
<div class="ui center aligned segment">
{% if client.oauthLogoURI %}
<img class="ui centered tiny image" src="{{ client.oauthLogoURI }}" alt="{{ client.oauthClientName }}">
{% endif %}
<br>
<button>Submit</button>
</form>
<p>{{ gettext('The application %(name)s is requesting access to:', name=client.oauthClientName) }}</p>
<p>
<strong>{{ grant.request.scope }}</strong>
</p>
<p>{{ gettext('from: %(user)s', user=user.name) }}</p>
<div class="ui buttons">
<form action="{{ request.url }}" method="post">
<input type="hidden" name="answer" value="deny" />
<input type="submit" class="ui negative button" value="{% trans %}Deny{% endtrans %}" />
</form>
<form action="{{ request.url }}" method="post">
<input type="hidden" name="answer" value="logout" />
<input type="submit" class="ui button" value="{% trans %}Switch user{% endtrans %}" />
</form>
<form action="{{ request.url }}" method="post">
<input type="hidden" name="answer" value="accept" />
<input type="submit" class="ui positive button" value="{% trans %}Accept{% endtrans %}" />
</form>
</div>
</div>
{% endblock %}

29
web/templates/login.html Normal file
View file

@ -0,0 +1,29 @@
{% extends 'base.html' %}
{% import 'fomanticui.j2' as sui %}
{% block content %}
<div class="ui clearing segment">
{% if logo_url %}
<img class="ui tiny centered image" src="{{ logo_url }}" alt="{{ website_name }}">
{% else %}
<i class="massive sign in icon"></i>
{% endif %}
<h2 class="ui center aligned header">
<div class="content">
{{ _("Sign in at %(website)s", website=website_name) }}
</div>
<div class="sub header">{% trans %}Log-in and manage your authorizations.{% endtrans %}</div>
</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="ui attached message {{ category }}">
{{ message }}
</div>
{% endfor %}
{% endwith %}
{{ sui.render_form(form, _("Sign in"), action=request.url) }}
</div>
{% endblock %}