forked from Github-Mirrors/canaille
Some authorization_code work
This commit is contained in:
parent
d75fcb163b
commit
ea98ca6702
10 changed files with 200 additions and 94 deletions
|
@ -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 $
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
71
web/oauth.py
Normal 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()
|
|
@ -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()
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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
29
web/templates/login.html
Normal 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 %}
|
Loading…
Reference in a new issue