Initial commit

master
informatic 2020-05-24 15:00:18 +02:00
commit dcc698cbec
43 changed files with 11184 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.ropeproject
*.py[co]
.env

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
FROM alpine:3.11.3@sha256:ddba4d27a7ffc3f86dd6c2f92041af252a1f23a8e742c90e6e1297bfa1bc0c45
EXPOSE 5000
WORKDIR /usr/src/app
RUN apk add --no-cache \
uwsgi-python3 \
python3 \
libpq git
# psycopg2 needs some extra build tools and headers. Install them and build in a
# single step in order not to pollute Docker layers
RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev postgresql-dev openldap-dev cyrus-sasl-dev libffi-dev && \
pip3 install --no-cache-dir psycopg2==2.8.4 pyasn1==0.4.8 pyasn1-modules==0.2.8 python-ldap==3.2.0 cffi==1.14.0 cryptography==2.9.2 && \
apk del --no-cache .build-deps
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
ENV FLASK_APP sso
ENV FLASK_ENV production
COPY . .
ENV prometheus_multiproc_dir /tmp
STOPSIGNAL SIGINT
USER uwsgi
CMD flask db upgrade && exec uwsgi --http-socket 0.0.0.0:5000 \
--processes 4 \
--plugins python3 \
--wsgi sso.wsgi:application \
--touch-reload sso/wsgi.py

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
version: "3"
services:
postgres:
# postgres:9.6.17-alpine
image: postgres@sha256:c48c87e19b1c9bdc9d1de8a0f53fa1c7f91f887ecc06d0c2efd3f3425090b6c0
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=secret
backend:
build: .
ports:
- 5000:5000
volumes:
- .:/usr/src/app
environment:
- TEMPLATES_AUTO_RELOAD=true
- LDAP_BIND_DN
- LDAP_BIND_PASSWORD
volumes:
pgdata:

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

45
migrations/alembic.ini Normal file
View File

@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

96
migrations/env.py Normal file
View File

@ -0,0 +1,96 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option(
"sqlalchemy.url",
str(current_app.extensions["migrate"].db.engine.url).replace("%", "%%"),
)
target_metadata = current_app.extensions["migrate"].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info("No changes in schema detected.")
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions["migrate"].configure_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,30 @@
"""Rename user to user_id
Revision ID: 5d43eb9bfe78
Revises: e4598273c1db
Create Date: 2020-05-22 06:08:05.034334
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "5d43eb9bfe78"
down_revision = "e4598273c1db"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("oauth2_code", "user", new_column_name="user_id")
op.alter_column("oauth2_token", "user", new_column_name="user_id")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("oauth2_code", "user_id", new_column_name="user")
op.alter_column("oauth2_token", "user_id", new_column_name="user")
# ### end Alembic commands ###

View File

@ -0,0 +1,81 @@
"""Initial migration
Revision ID: e4598273c1db
Revises:
Create Date: 2020-05-21 22:44:54.235671
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "e4598273c1db"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"oauth2_client",
sa.Column("client_id", sa.String(length=48), nullable=True),
sa.Column("client_secret", sa.String(length=120), nullable=True),
sa.Column("client_id_issued_at", sa.Integer(), nullable=False),
sa.Column("client_secret_expires_at", sa.Integer(), nullable=False),
sa.Column("client_metadata", sa.Text(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_oauth2_client_client_id"), "oauth2_client", ["client_id"], unique=False
)
op.create_table(
"oauth2_code",
sa.Column("code", sa.String(length=120), nullable=False),
sa.Column("client_id", sa.String(length=48), nullable=True),
sa.Column("redirect_uri", sa.Text(), nullable=True),
sa.Column("response_type", sa.Text(), nullable=True),
sa.Column("scope", sa.Text(), nullable=True),
sa.Column("nonce", sa.Text(), nullable=True),
sa.Column("auth_time", sa.Integer(), nullable=False),
sa.Column("code_challenge", sa.Text(), nullable=True),
sa.Column("code_challenge_method", sa.String(length=48), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user", sa.String(length=40), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("code"),
)
op.create_table(
"oauth2_token",
sa.Column("client_id", sa.String(length=48), nullable=True),
sa.Column("token_type", sa.String(length=40), nullable=True),
sa.Column("access_token", sa.String(length=255), nullable=False),
sa.Column("refresh_token", sa.String(length=255), nullable=True),
sa.Column("scope", sa.Text(), nullable=True),
sa.Column("revoked", sa.Boolean(), nullable=True),
sa.Column("issued_at", sa.Integer(), nullable=False),
sa.Column("expires_in", sa.Integer(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user", sa.String(length=40), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("access_token"),
)
op.create_index(
op.f("ix_oauth2_token_refresh_token"),
"oauth2_token",
["refresh_token"],
unique=False,
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_oauth2_token_refresh_token"), table_name="oauth2_token")
op.drop_table("oauth2_token")
op.drop_table("oauth2_code")
op.drop_index(op.f("ix_oauth2_client_client_id"), table_name="oauth2_client")
op.drop_table("oauth2_client")
# ### end Alembic commands ###

32
requirements.txt Normal file
View File

@ -0,0 +1,32 @@
alembic==1.4.2
Authlib==0.14.3
certifi==2020.4.5.1
cffi==1.14.0
chardet==3.0.4
click==7.1.2
cryptography==2.9.2
environs==7.4.0
Flask==1.1.2
Flask-Login==0.5.0
Flask-Migrate==2.5.3
Flask-SQLAlchemy==2.4.1
Flask-WTF==0.14.3
idna==2.9
itsdangerous==1.1.0
Jinja2==2.11.2
Mako==1.1.2
MarkupSafe==1.1.1
marshmallow==3.6.0
pyasn1==0.4.8
pyasn1-modules==0.2.8
pycparser==2.20
python-dateutil==2.8.1
python-dotenv==0.13.0
python-editor==1.0.4
python-ldap==3.2.0
requests==2.23.0
six==1.15.0
SQLAlchemy==1.3.17
urllib3==1.25.9
Werkzeug==1.0.1
WTForms==2.3.1

34
sso/__init__.py Normal file
View File

@ -0,0 +1,34 @@
import flask
from sso.extensions import db, migrate, login_manager
from sso.oauth2 import config_oauth
def create_app():
app = flask.Flask(
__name__, template_folder="../templates", static_folder="../static"
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config.from_object("sso.settings")
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
config_oauth(app)
import sso.views
app.register_blueprint(sso.views.bp)
from werkzeug.middleware.proxy_fix import ProxyFix
if app.config.get('PROXYFIX_ENABLE'):
print('gnuj')
app.wsgi_app = ProxyFix(
app.wsgi_app,
app.config.get('PROXYFIX_NUM_PROXIES'),
app.config.get('PROXYFIX_NUM_PROXIES'),
app.config.get('PROXYFIX_NUM_PROXIES'),
app.config.get('PROXYFIX_NUM_PROXIES'),
)
return app

87
sso/directory.py Normal file
View File

@ -0,0 +1,87 @@
import re
import requests
import ldap
import logging
from flask import current_app as app
from sso.extensions import login_manager
def connect_to_ldap():
conn = ldap.initialize(app.config["LDAP_URL"])
conn.simple_bind_s(app.config["LDAP_BIND_DN"], app.config["LDAP_BIND_PASSWORD"])
return conn
def check_credentials(username, password):
if app.config.get("TESTING") == True:
return True
conn = ldap.initialize(app.config["LDAP_URL"])
try:
conn.simple_bind_s(app.config["LDAP_DN_STRING"] % username, password)
return True
except ldap.LDAPError:
return False
class LDAPUserProxy(object):
def __init__(self, username):
self.username = re.sub(app.config["LDAP_STRIP_RE"], "", username)
self.is_authenticated = True
self.is_anonymous = False
if app.config.get("TESTING") == True:
self.gecos = "Testing User"
self.mifare_hashes = []
self.phone = "123456789"
self.personal_email = "testing@gmail.com"
return
conn = connect_to_ldap()
res = conn.search_s(
app.config["LDAP_PEOPLE_BASEDN"],
ldap.SCOPE_SUBTREE,
app.config["LDAP_UID_FILTER"] % self.username,
)
if len(res) != 1:
raise Exception("No such username.")
dn, data = res[0]
print(dn, data)
self.username = data.get("uid", [b""])[0].decode() or None
self.gecos = data.get("gecos", [b""])[0].decode() or None
self.mifare_hashes = data.get("mifareIDHash", [])
self.phone = data.get("mobile", [b""])[0].decode() or None
self.personal_email = data.get("mailRoutingAddress", [b""])[0].decode() or None
def __repr__(self):
active = "active" if self.is_active else "inactive"
return "<LDAPUserProxy {}, {}>".format(self.username, active)
@property
def email(self):
return self.username + "@hackerspace.pl"
# @cached_property
def is_active(self):
url = "https://kasownik.hackerspace.pl/api/judgement/{}.json"
try:
r = requests.get(url.format(self.username))
return bool(r.json()["content"])
except Exception as e:
logging.error("When getting data from Kasownik: {}".format(e))
# Fail-safe.
return True
def get_id(self):
return self.username
# Required by authlib sqla integration
def get_user_id(self):
return self.get_id()
@login_manager.user_loader
def load_user(user_id):
return LDAPUserProxy(user_id)

9
sso/extensions.py Normal file
View File

@ -0,0 +1,9 @@
import flask_sqlalchemy
import flask_migrate
import flask_login
db = flask_sqlalchemy.SQLAlchemy()
migrate = flask_migrate.Migrate()
login_manager = flask_login.LoginManager()
login_manager.login_view = "/login"

9
sso/forms.py Normal file
View File

@ -0,0 +1,9 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField
from wtforms.validators import DataRequired
class LoginForm(FlaskForm):
username = StringField("username", validators=[DataRequired()])
password = PasswordField("password", validators=[DataRequired()])
remember = BooleanField("remember me")

38
sso/models.py Normal file
View File

@ -0,0 +1,38 @@
from authlib.integrations.sqla_oauth2 import (
OAuth2ClientMixin,
OAuth2TokenMixin,
OAuth2AuthorizationCodeMixin,
)
from sso.extensions import db
from sso.directory import LDAPUserProxy
class Client(db.Model, OAuth2ClientMixin):
__tablename__ = "oauth2_client"
id = db.Column(db.Integer, primary_key=True)
class AuthorizationCode(db.Model, OAuth2AuthorizationCodeMixin):
__tablename__ = "oauth2_code"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.String(40), nullable=False)
class Token(db.Model, OAuth2TokenMixin):
__tablename__ = "oauth2_token"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.String(40), nullable=False)
@property
def user(self):
return LDAPUserProxy(self.user_id)
client_id = db.Column(db.String(48))
client = db.relationship(
"Client",
primaryjoin="Token.client_id == Client.client_id",
foreign_keys=[client_id],
)

131
sso/oauth2.py Normal file
View File

@ -0,0 +1,131 @@
from authlib.integrations.flask_oauth2 import AuthorizationServer, ResourceProtector
from authlib.integrations.sqla_oauth2 import (
create_query_client_func,
create_save_token_func,
create_bearer_token_validator,
)
from authlib.oauth2.rfc6749.grants import (
AuthorizationCodeGrant as _AuthorizationCodeGrant,
)
from authlib.oidc.core.grants import (
OpenIDCode as _OpenIDCode,
OpenIDImplicitGrant as _OpenIDImplicitGrant,
OpenIDHybridGrant as _OpenIDHybridGrant,
)
from authlib.oidc.core import UserInfo
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
DUMMY_JWT_CONFIG = {
"key": "secret-key",
"alg": "HS256",
"iss": "https://sso.hackerspace.pl",
"exp": 3600,
}
def exists_nonce(nonce, req):
exists = AuthorizationCode.query.filter_by(
client_id=req.client_id, nonce=nonce
).first()
return bool(exists)
def generate_user_info(user, scope):
return UserInfo(sub=str(user.get_user_id()), name=user.username)
def create_authorization_code(client, grant_user, request):
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.get_id(),
nonce=nonce,
)
db.session.add(item)
db.session.commit()
return code
class AuthorizationCodeGrant(_AuthorizationCodeGrant):
def create_authorization_code(self, client, grant_user, request):
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
def delete_authorization_code(self, authorization_code):
db.session.delete(authorization_code)
db.session.commit()
def authenticate_user(self, authorization_code):
return LDAPUserProxy(authorization_code.user_id)
class OpenIDCode(_OpenIDCode):
def exists_nonce(self, nonce, request):
return exists_nonce(nonce, request)
def get_jwt_config(self, grant):
return app.get('JWT_CONFIG')
def generate_user_info(self, user, scope):
return generate_user_info(user, scope)
class ImplicitGrant(_OpenIDImplicitGrant):
def exists_nonce(self, nonce, request):
return exists_nonce(nonce, request)
def get_jwt_config(self, grant):
return app.get('JWT_CONFIG')
def generate_user_info(self, user, scope):
return generate_user_info(user, scope)
class HybridGrant(_OpenIDHybridGrant):
def create_authorization_code(self, client, grant_user, request):
return create_authorization_code(client, grant_user, request)
def exists_nonce(self, nonce, request):
return exists_nonce(nonce, request)
def get_jwt_config(self):
return DUMMY_JWT_CONFIG
def generate_user_info(self, user, scope):
return generate_user_info(user, scope)
authorization = AuthorizationServer()
require_oauth = ResourceProtector()
def config_oauth(app):
query_client = create_query_client_func(db.session, Client)
save_token = create_save_token_func(db.session, Token)
authorization.init_app(app, query_client=query_client, save_token=save_token)
# support all openid grants
authorization.register_grant(
AuthorizationCodeGrant, [OpenIDCode(require_nonce=False)]
)
authorization.register_grant(ImplicitGrant)
authorization.register_grant(HybridGrant)
# protect resource
bearer_cls = create_bearer_token_validator(db.session, Token)
require_oauth.register_token_validator(bearer_cls())

46
sso/settings.py Normal file
View File

@ -0,0 +1,46 @@
from environs import Env
env = Env()
env.read_env()
SECRET_KEY = env.str("SECRET_KEY", default="randomstring")
db_username = env.str("DATABASE_USERNAME", default="postgres")
db_password = env.str("DATABASE_PASSWORD", default="secret")
db_hostname = env.str("DATABASE_HOSTNAME", default="postgres")
db_name = env.str("DATABASE_NAME", default="postgres")
SQLALCHEMY_DATABASE_URI = env.str(
"DATABASE_URI",
default="postgresql+psycopg2://%s:%s@%s/%s"
% (db_username, db_password, db_hostname, db_name),
)
TEMPLATES_AUTO_RELOAD = env.bool("TEMPLATES_AUTO_RELOAD", default=False)
LDAP_STRIP_RE = env.str("LDAP_STRIP_RE", default=r'[()"\'&|<>=~!*]+')
LDAP_URL = env.str("LDAP_URL", default="ldaps://ldap.hackerspace.pl")
LDAP_DN_STRING = env.str(
"LDAP_DN_STRING", default="uid=%s,ou=People,dc=hackerspace,dc=pl"
)
LDAP_PEOPLE_BASEDN = env.str(
"LDAP_PEOPLE_BASEDN", default="ou=People,dc=hackerspace,dc=pl"
)
LDAP_UID_FILTER = env.str(
"LDAP_UID_FILTER", default="(&(objectClass=hsMember)(uid=%s))"
)
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)
JWT_CONFIG = {
"key": "secret-key",
"alg": "HS256",
"iss": "https://sso.hackerspace.pl",
"exp": 3600,
}

142
sso/views.py Normal file
View File

@ -0,0 +1,142 @@
from flask import (
Blueprint,
render_template,
abort,
redirect,
request,
url_for,
flash,
jsonify,
current_app,
)
from flask_login import login_required, current_user, login_user, logout_user
from sso.directory import LDAPUserProxy, check_credentials
from sso.models import db, Token
from sso.forms import LoginForm
from sso.oauth2 import authorization, require_oauth
from authlib.oauth2 import OAuth2Error
from authlib.integrations.flask_oauth2 import current_token
bp = Blueprint("sso", __name__)
@bp.route("/")
@bp.route("/profile")
@login_required
def profile():
return render_template(
"profile.html",
tokens=Token.query.filter(Token.user_id == current_user.username),
)
@bp.route("/token/<int:id>/revoke", methods=["POST"])
@login_required
def token_revoke(id):
token = Token.query.filter(
Token.user_id == current_user.username, Token.id == id
).first()
if not token:
abort(404)
db.session.delete(token)
db.session.commit()
return redirect("/")
@bp.route("/login", methods=["GET", "POST"])
def login():
form = LoginForm()
next = request.args.get("next")
if form.validate_on_submit():
username, password = form.data["username"], form.data["password"]
if not check_credentials(username, password):
flash("Invalid username or password")
return render_template("login_oauth.html", form=form, next=next)
login_user(LDAPUserProxy(username), form.data["remember"])
flash("Logged in successfully.")
return redirect(next or url_for("profile"))
return render_template("login_oauth.html", form=form, next=next)
@bp.route("/logout")
def logout():
logout_user()
return redirect("/")
@bp.route("/oauth/authorize", methods=["GET", "POST"])
@login_required
def authorize():
if request.method == "GET":
try:
grant = authorization.validate_consent_request(end_user=current_user)
except OAuth2Error as error:
return jsonify(dict(error.get_body()))
return render_template(
"oauthorize.html", user=current_user, grant=grant, client=grant.client
)
if request.form["confirm"]:
grant_user = current_user
else:
grant_user = None
return authorization.create_authorization_response(grant_user=grant_user)
@bp.route("/oauth/token", methods=["POST"])
def issue_token():
return authorization.create_token_response()
# HSWAW specific endpoint
@bp.route("/api/profile")
@bp.route("/api/1/profile")
@require_oauth("profile:read")
def api_profile():
user = current_token.user
print(user.email, user.username, user.gecos, user.phone, user.personal_email)
return jsonify(
email=user.email,
username=user.username,
gecos=user.gecos,
phone=user.phone,
personal_email=user.personal_email,
)
# OpenIDConnect userinfo
@bp.route("/api/1/userinfo")
# @require_oauth("profile:read")
@require_oauth("openid")
def api_userinfo():
user = current_token.user
# user = LDAPUserProxy(flask.request.oauth.user)
return jsonify(
sub=user.username,
name=user.gecos,
email=user.email,
preferred_username=user.username,
nickname=user.username,
)
@bp.route("/.well-known/openid-configuration")
def openid_configuration():
return jsonify(
{
"issuer": current_app.config['JWT_CONFIG']['iss'],
"authorization_endpoint": url_for(".authorize", _external=True),
"token_endpoint": url_for(".issue_token", _external=True),
"userinfo_endpoint": url_for(".api_userinfo", _external=True),
"response_types_supported": ["code", "id_token", "token id_token"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
],
}
)

3
sso/wsgi.py Normal file
View File

@ -0,0 +1,3 @@
import sso
application = sso.create_app()

BIN
static/concrete-texture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

29
static/css/authorize.css Normal file
View File

@ -0,0 +1,29 @@
body {
padding-top: 20px;
padding-bottom: 40px;
background: #eee url(/static/concrete-texture.png);
}
#authorize-container{
position: relative;
margin: 80px auto;
padding: 40px 40px 40px;
text-align: center;
background: #fff;
border: 1px solid #ccc;
max-width: 400px;
}
#authorize-container h2 {
margin-bottom: 10px;
}
#authorize-container p {
font-size: 14px;
}
td.placeholder {
text-align: center;
font-style: italic;
opacity: 0.5;
}

View File

@ -0,0 +1,587 @@
/*!
* Bootstrap v3.3.7 (http://getbootstrap.com)
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
.btn-default,
.btn-primary,
.btn-success,
.btn-info,
.btn-warning,
.btn-danger {
text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
}
.btn-default:active,
.btn-primary:active,
.btn-success:active,
.btn-info:active,
.btn-warning:active,
.btn-danger:active,
.btn-default.active,
.btn-primary.active,
.btn-success.active,
.btn-info.active,
.btn-warning.active,
.btn-danger.active {
-webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
}
.btn-default.disabled,
.btn-primary.disabled,
.btn-success.disabled,
.btn-info.disabled,
.btn-warning.disabled,
.btn-danger.disabled,
.btn-default[disabled],
.btn-primary[disabled],
.btn-success[disabled],
.btn-info[disabled],
.btn-warning[disabled],
.btn-danger[disabled],
fieldset[disabled] .btn-default,
fieldset[disabled] .btn-primary,
fieldset[disabled] .btn-success,
fieldset[disabled] .btn-info,
fieldset[disabled] .btn-warning,
fieldset[disabled] .btn-danger {
-webkit-box-shadow: none;
box-shadow: none;
}
.btn-default .badge,
.btn-primary .badge,
.btn-success .badge,
.btn-info .badge,
.btn-warning .badge,
.btn-danger .badge {
text-shadow: none;
}
.btn:active,
.btn.active {
background-image: none;
}
.btn-default {
text-shadow: 0 1px 0 #fff;
background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #dbdbdb;
border-color: #ccc;
}
.btn-default:hover,
.btn-default:focus {
background-color: #e0e0e0;
background-position: 0 -15px;
}
.btn-default:active,
.btn-default.active {
background-color: #e0e0e0;
border-color: #dbdbdb;
}
.btn-default.disabled,
.btn-default[disabled],
fieldset[disabled] .btn-default,
.btn-default.disabled:hover,
.btn-default[disabled]:hover,
fieldset[disabled] .btn-default:hover,
.btn-default.disabled:focus,
.btn-default[disabled]:focus,
fieldset[disabled] .btn-default:focus,
.btn-default.disabled.focus,
.btn-default[disabled].focus,
fieldset[disabled] .btn-default.focus,
.btn-default.disabled:active,
.btn-default[disabled]:active,
fieldset[disabled] .btn-default:active,
.btn-default.disabled.active,
.btn-default[disabled].active,
fieldset[disabled] .btn-default.active {
background-color: #e0e0e0;
background-image: none;
}
.btn-primary {
background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #245580;
}
.btn-primary:hover,
.btn-primary:focus {
background-color: #265a88;
background-position: 0 -15px;
}
.btn-primary:active,
.btn-primary.active {
background-color: #265a88;
border-color: #245580;
}
.btn-primary.disabled,
.btn-primary[disabled],
fieldset[disabled] .btn-primary,
.btn-primary.disabled:hover,
.btn-primary[disabled]:hover,
fieldset[disabled] .btn-primary:hover,
.btn-primary.disabled:focus,
.btn-primary[disabled]:focus,
fieldset[disabled] .btn-primary:focus,
.btn-primary.disabled.focus,
.btn-primary[disabled].focus,
fieldset[disabled] .btn-primary.focus,
.btn-primary.disabled:active,
.btn-primary[disabled]:active,
fieldset[disabled] .btn-primary:active,
.btn-primary.disabled.active,
.btn-primary[disabled].active,
fieldset[disabled] .btn-primary.active {
background-color: #265a88;
background-image: none;
}
.btn-success {
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #3e8f3e;
}
.btn-success:hover,
.btn-success:focus {
background-color: #419641;
background-position: 0 -15px;
}
.btn-success:active,
.btn-success.active {
background-color: #419641;
border-color: #3e8f3e;
}
.btn-success.disabled,
.btn-success[disabled],
fieldset[disabled] .btn-success,
.btn-success.disabled:hover,
.btn-success[disabled]:hover,
fieldset[disabled] .btn-success:hover,
.btn-success.disabled:focus,
.btn-success[disabled]:focus,
fieldset[disabled] .btn-success:focus,
.btn-success.disabled.focus,
.btn-success[disabled].focus,
fieldset[disabled] .btn-success.focus,
.btn-success.disabled:active,
.btn-success[disabled]:active,
fieldset[disabled] .btn-success:active,
.btn-success.disabled.active,
.btn-success[disabled].active,
fieldset[disabled] .btn-success.active {
background-color: #419641;
background-image: none;
}
.btn-info {
background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #28a4c9;
}
.btn-info:hover,
.btn-info:focus {
background-color: #2aabd2;
background-position: 0 -15px;
}
.btn-info:active,
.btn-info.active {
background-color: #2aabd2;
border-color: #28a4c9;
}
.btn-info.disabled,
.btn-info[disabled],
fieldset[disabled] .btn-info,
.btn-info.disabled:hover,
.btn-info[disabled]:hover,
fieldset[disabled] .btn-info:hover,
.btn-info.disabled:focus,
.btn-info[disabled]:focus,
fieldset[disabled] .btn-info:focus,
.btn-info.disabled.focus,
.btn-info[disabled].focus,
fieldset[disabled] .btn-info.focus,
.btn-info.disabled:active,
.btn-info[disabled]:active,
fieldset[disabled] .btn-info:active,
.btn-info.disabled.active,
.btn-info[disabled].active,
fieldset[disabled] .btn-info.active {
background-color: #2aabd2;
background-image: none;
}
.btn-warning {
background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #e38d13;
}
.btn-warning:hover,
.btn-warning:focus {
background-color: #eb9316;
background-position: 0 -15px;
}
.btn-warning:active,
.btn-warning.active {
background-color: #eb9316;
border-color: #e38d13;
}
.btn-warning.disabled,
.btn-warning[disabled],
fieldset[disabled] .btn-warning,
.btn-warning.disabled:hover,
.btn-warning[disabled]:hover,
fieldset[disabled] .btn-warning:hover,
.btn-warning.disabled:focus,
.btn-warning[disabled]:focus,
fieldset[disabled] .btn-warning:focus,
.btn-warning.disabled.focus,
.btn-warning[disabled].focus,
fieldset[disabled] .btn-warning.focus,
.btn-warning.disabled:active,
.btn-warning[disabled]:active,
fieldset[disabled] .btn-warning:active,
.btn-warning.disabled.active,
.btn-warning[disabled].active,
fieldset[disabled] .btn-warning.active {
background-color: #eb9316;
background-image: none;
}
.btn-danger {
background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-color: #b92c28;
}
.btn-danger:hover,
.btn-danger:focus {
background-color: #c12e2a;
background-position: 0 -15px;
}
.btn-danger:active,
.btn-danger.active {
background-color: #c12e2a;
border-color: #b92c28;
}
.btn-danger.disabled,
.btn-danger[disabled],
fieldset[disabled] .btn-danger,
.btn-danger.disabled:hover,
.btn-danger[disabled]:hover,
fieldset[disabled] .btn-danger:hover,
.btn-danger.disabled:focus,
.btn-danger[disabled]:focus,
fieldset[disabled] .btn-danger:focus,
.btn-danger.disabled.focus,
.btn-danger[disabled].focus,
fieldset[disabled] .btn-danger.focus,
.btn-danger.disabled:active,
.btn-danger[disabled]:active,
fieldset[disabled] .btn-danger:active,
.btn-danger.disabled.active,
.btn-danger[disabled].active,
fieldset[disabled] .btn-danger.active {
background-color: #c12e2a;
background-image: none;
}
.thumbnail,
.img-thumbnail {
-webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
}
.dropdown-menu > li > a:hover,
.dropdown-menu > li > a:focus {
background-color: #e8e8e8;
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
background-repeat: repeat-x;
}
.dropdown-menu > .active > a,
.dropdown-menu > .active > a:hover,
.dropdown-menu > .active > a:focus {
background-color: #2e6da4;
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
background-repeat: repeat-x;
}
.navbar-default {
background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-radius: 4px;
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
}
.navbar-default .navbar-nav > .open > a,
.navbar-default .navbar-nav > .active > a {
background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
background-repeat: repeat-x;
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
}
.navbar-brand,
.navbar-nav > li > a {
text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
}
.navbar-inverse {
background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
background-repeat: repeat-x;
border-radius: 4px;
}
.navbar-inverse .navbar-nav > .open > a,
.navbar-inverse .navbar-nav > .active > a {
background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
background-repeat: repeat-x;
-webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
}
.navbar-inverse .navbar-brand,
.navbar-inverse .navbar-nav > li > a {
text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
}
.navbar-static-top,
.navbar-fixed-top,
.navbar-fixed-bottom {
border-radius: 0;
}
@media (max-width: 767px) {
.navbar .navbar-nav .open .dropdown-menu > .active > a,
.navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
.navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
color: #fff;
background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
background-repeat: repeat-x;
}
}
.alert {
text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
}
.alert-success {
background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
background-repeat: repeat-x;
border-color: #b2dba1;
}
.alert-info {
background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
background-repeat: repeat-x;
border-color: #9acfea;
}
.alert-warning {
background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
background-repeat: repeat-x;
border-color: #f5e79e;
}
.alert-danger {
background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
background-repeat: repeat-x;
border-color: #dca7a7;
}
.progress {
background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
background-repeat: repeat-x;
}
.progress-bar {
background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
background-repeat: repeat-x;
}
.progress-bar-success {
background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));