~sircmpwn/meta.sr.ht

4d11e6017625ef2eb34f09c0522c4486d70bd8b9 — 2xsaiko 1 year, 3 months ago aa9c0eb 0.45.0
Implement PAM authentication
M config.example.ini => config.example.ini +36 -0
@@ 143,3 143,39 @@ enabled=no
# Get your keys at https://dashboard.stripe.com/account/apikeys
stripe-public-key=
stripe-secret-key=

[meta.sr.ht::auth]
#
# What authentication method to use.
#   builtin:  use sr.ht builtin authentication
#   unix-pam: use Unix PAM authentication
#auth-method=builtin

[meta.sr.ht::auth::unix-pam]
#
# The default email domain to assign to newly created users when they first log
# in.
# User's email will be set to <username>@<email-default-domain>
email-default-domain=example.com
#
# The PAM service to use for logging in.
#service=sshd
#
# Whether to automatically create new users when authentication succeeds but the
# user is not in the database.
create-users=yes
#
# The UNIX group users need to belong to to have access to sourcehut.
# If set,
# only users belonging to this group will be able to log into the site.
# If unset, any user on the system is able to log in if PAM authentication
# succeeds.
user-group=
#
# The UNIX group users need to belong to to have administrator permissions.
# If set, administrator status on the site will be synced with group
# association. Additionally, any user of this group will also be able to access
# sourcehut even if they are not in the group specified in user-group.
# If unset, administrator status can be manually assigned from the web
# interface.
admin-group=wheel

R metasrht-createuser => metasrht-manageuser +88 -43
@@ 1,50 1,84 @@
#!/usr/bin/env python3
import sys
import os

import sys
from getopt import getopt, GetoptError
from getpass import getpass
from metasrht.auth import hash_password
from metasrht.blueprints import auth
from metasrht.types import User

from srht.config import cfg
from srht.database import DbSession
from srht.validation import Validation
from srht.oauth import UserType
from srht.validation import Validation

from metasrht import auth_validation
from metasrht.auth import auth_method
from metasrht.auth.base import get_user
from metasrht.auth.builtin import hash_password
from metasrht.types import User

USER_TYPES = [x.value for x in UserType]


def print_usage():
    print("Usage:")
    print("    metasrht-createuser [-t] <username> <email>")
    print("Options:")
    print("    -t <user_type>")
    print("    -s read initial password from stdin")
    print(f"""Usage:
    {sys.argv[0]} [-fmPps] [-e <EMAIL>] [-t <USER_TYPE>] <USERNAME>

Options:
    -e <EMAIL>      set user email
    -f              perform action even if using different authentication method
    -m              modify existing user
    -P              clear password
    -p              set password (default if creating a new user)
    -s              read initial password from stdin (only effective with -p)
    -t <USER_TYPE>  set user type to USER_TYPE; USER_TYPE must be one of these
                    values: unconfirmed, active_non_paying, active_free,
                    active_paying, active_delinquent, admin, unknown, suspended""")


def get_args():
    try:
        opts, args = getopt(sys.argv[1:], "t:s")
        opts, args = getopt(sys.argv[1:], "e:fmPpst:")
    except GetoptError as ex:
        print(ex, file=sys.stderr)
        print_usage()
        sys.exit(1)

    if len(args) != 2:
        print("Invalid argument count", file=sys.stderr)
    if len(args) == 0:
        print("Username not specified", file=sys.stderr)
        print_usage()
        sys.exit(1)

    if len(args) > 1:
        print("Too many arguments", file=sys.stderr)
        print_usage()
        sys.exit(1)

    username, email = args
    force = ("-f", "") in opts
    modify_existing = ("-m", "") in opts
    clear_password = ("-P", "") in opts
    set_password = ("-p", "") in opts
    stdin = ("-s", "") in opts
    email = [y for x, y in opts if x == "-e"]
    email = email[0] if email else None
    user_type = [y for x, y in opts if x == "-t"]
    user_type = user_type[0] if user_type else None

    username = args[0]

    if clear_password and set_password:
        sys.exit('Only one of -P, -p can be present at the same time')

    if not modify_existing and not clear_password:
        set_password = True

    if not modify_existing and email is None:
        sys.exit("Must specify -e when creating a new user!")

    user_types = [y for x, y in opts if x == "-t"]
    user_type = user_types[0] if user_types else None
    if user_type and user_type not in USER_TYPES:
        sys.exit(f"-t must be one of {USER_TYPES}")

    stdin = ("-s", "") in opts
    return force, modify_existing, clear_password, set_password, stdin, email, \
           user_type, username

    return username, email, user_type, stdin

def get_password(stdin):
    if not stdin:


@@ 58,47 92,58 @@ def get_password(stdin):
    else:
        return sys.stdin.readline().rstrip(os.linesep)


def error_on_invalid(valid):
    if not valid.ok:
        for error in valid.errors:
            print(error.message, file=sys.stderr)
        sys.exit(1)


def validate_user(username, email):
    valid = Validation({})
    auth.validate_username(valid, username)
    auth.validate_email(valid, email)
    auth_validation.validate_username(valid, username)
    auth_validation.validate_email(valid, email)
    error_on_invalid(valid)


def validate_password(password):
    valid = Validation({})
    auth.validate_password(valid, password)
    auth_validation.validate_password(valid, password)
    error_on_invalid(valid)

def create(username, email, password, user_type):

if __name__ == '__main__':
    force, modify_existing, clear_password, set_password, stdin, email, \
        user_type, username = get_args()

    if not force and auth_method != 'builtin':
        sys.exit("Can't create accounts if not using builtin authentication!")

    db = DbSession(cfg("meta.sr.ht", "connection-string"))
    db.init()

    user = User(username)
    user.email = email
    user.password = hash_password(password)
    user.invites = cfg("meta.sr.ht::settings", "user-invites", default=0)

    if user_type:
    if modify_existing:
        user = get_user(username)
        if user is None:
            sys.exit(f"User {username} not found!")
    else:
        validate_user(username, email)
        user = User(username)
        user.invites = cfg("meta.sr.ht::settings", "user-invites", default=0)
        db.session.add(user)

    if set_password:
        password = get_password(stdin)
        validate_password(password)
        user.password = hash_password(password)
    elif clear_password:
        user.password = ''

    if email is not None:
        user.email = email

    if user_type is not None:
        user.user_type = UserType[user_type]

    db.session.add(user)
    db.session.commit()

    return user


if __name__ == '__main__':
    username, email, user_type, stdin = get_args()
    validate_user(username, email)

    password = get_password(stdin)
    validate_password(password)

    user = create(username, email, password, user_type)
    print(f"User created: {user}")

M metasrht/app.py => metasrht/app.py +7 -4
@@ 1,10 1,10 @@
import metasrht.webhooks
from metasrht.oauth import MetaOAuthService, MetaOAuthProvider
from metasrht.types import User, UserType
from srht.config import cfg
from srht.database import DbSession
from srht.flask import SrhtFlask
from urllib.parse import quote_plus

from metasrht.auth import allow_registration, is_external_auth
from metasrht.oauth import MetaOAuthService, MetaOAuthProvider
from metasrht.types import UserType

db = DbSession(cfg("meta.sr.ht", "connection-string"))
db.init()


@@ 39,6 39,9 @@ class MetaApp(SrhtFlask):
        register_api(self)
        self.register_blueprint(gql_blueprint)

        self.jinja_env.globals['allow_registration'] = allow_registration
        self.jinja_env.globals['is_external_auth'] = is_external_auth

        if cfg("meta.sr.ht::billing", "enabled") == "yes":
            from metasrht.blueprints.billing import billing
            self.register_blueprint(billing)

D metasrht/auth.py => metasrht/auth.py +0 -10
@@ 1,10 0,0 @@
import bcrypt


def hash_password(password):
    return bcrypt.hashpw(password.encode('utf-8'),
            salt=bcrypt.gensalt()).decode('utf-8')


def check_password(password, hash):
    return bcrypt.checkpw(password.encode('utf-8'), hash.encode('utf-8'))

A metasrht/auth/__init__.py => metasrht/auth/__init__.py +37 -0
@@ 0,0 1,37 @@
from srht.config import cfg, cfgb
from srht.validation import Validation

from metasrht.auth.builtin import BuiltinAuthMethod
from metasrht.auth.pam import PamAuthMethod
from metasrht.types.user import User

auth_method = cfg('meta.sr.ht::auth', 'auth-method', 'builtin')

_auth_method_types = {
    'builtin': BuiltinAuthMethod,
    'unix-pam': PamAuthMethod,
}

if auth_method not in _auth_method_types:
    methods = ', '.join(k for k in _auth_method_types.keys())
    raise Exception(f"invalid auth-method {auth_method}; "
                    f"must be one of {methods}")

_auth_method = _auth_method_types[auth_method]()


def allow_registration() -> bool:
    return not is_external_auth() and \
           cfgb("meta.sr.ht::settings", "registration")


def is_external_auth() -> bool:
    return auth_method != 'builtin'


def user_valid(valid: Validation, user: str, password: str) -> bool:
    return _auth_method.user_valid(valid, user, password)


def prepare_user(user: str) -> User:
    return _auth_method.prepare_user(user)

A metasrht/auth/base.py => metasrht/auth/base.py +20 -0
@@ 0,0 1,20 @@
from typing import Optional

from srht.validation import Validation

from metasrht.types.user import User


def get_user(username: str) -> Optional[User]:
    return User.query.filter(
        (User.username == username.lower()) |
        (User.email == username.strip())).one_or_none()


class AuthMethod:
    def user_valid(self, valid: Validation, username: str, password: str) \
            -> bool:
        raise NotImplementedError()

    def prepare_user(self, username: str) -> User:
        raise NotImplementedError()

A metasrht/auth/builtin.py => metasrht/auth/builtin.py +33 -0
@@ 0,0 1,33 @@
import bcrypt
from srht.validation import Validation

from metasrht.auth.base import AuthMethod, get_user
from metasrht.types.user import User


class BuiltinAuthMethod(AuthMethod):
    def user_valid(self, valid: Validation, username: str, password: str) \
            -> bool:
        username = get_user(username)

        valid.expect(username is not None, "Username or password incorrect")

        if valid.ok:
            valid.expect(username.password, "Username or password incorrect")

        if valid.ok:
            valid.expect(check_password(password, username.password),
                         "Username or password incorrect")

        return valid.ok

    def prepare_user(self, username: str) -> User:
        return get_user(username)


def check_password(password: str, hash: str) -> bool:
    return bcrypt.checkpw(password.encode(), hash.encode())


def hash_password(password: str) -> str:
    return bcrypt.hashpw(password.encode(), salt=bcrypt.gensalt()).decode()

A metasrht/auth/pam.py => metasrht/auth/pam.py +121 -0
@@ 0,0 1,121 @@
import grp
import os
import pwd
import sys

from srht.config import cfg, cfgb
from srht.database import db
from srht.validation import Validation

from metasrht.audit import audit_log
from metasrht.auth.base import AuthMethod, get_user
from metasrht.auth_validation import validate_username
from metasrht.types import User, UserType


class PamAuthMethod(AuthMethod):
    def __init__(self):
        try:
            from pam import pam
            self.pam = pam
        except ImportError:
            print(
                "could not import 'pam', this is necessary for PAM "
                "authentication; please install python_pam or change "
                "'auth-method=unix-pam' in the configuration file.",
                file=sys.stderr)
            sys.exit(1)

        self.domain = cfg('meta.sr.ht::auth::unix-pam', 'email-default-domain')
        self.service = cfg('meta.sr.ht::auth::unix-pam', 'service', 'sshd')
        self.create_users = cfgb('meta.sr.ht::auth::unix-pam', 'create-users')
        user_group = cfg('meta.sr.ht::auth::unix-pam', 'user-group', '')
        admin_group = cfg('meta.sr.ht::auth::unix-pam', 'admin-group', '')
        self.user_group = grp.getgrnam(user_group).gr_gid if user_group \
            else None
        self.admin_group = grp.getgrnam(admin_group).gr_gid if admin_group \
            else None

    def user_valid(self, valid: Validation, username: str, password: str) \
            -> bool:
        user = get_user(username)

        if user is None:
            # Since users will get auto-created here (in prepare_user), validate
            # the username to ensure valid names in the database
            valid_dummy = Validation({})

            validate_username(valid_dummy, username)

            if not valid_dummy.ok:
                valid.error('Username or password incorrect')
                return False

            if not self.create_users:
                valid.error('Username or password incorrect')
                return False
        else:
            # Make sure we're using the actual user name for PAM authentication,
            # even when the user logs in with the email address
            username = user.username

        if not self.pam().authenticate(username, password, self.service):
            valid.error('Username or password incorrect')
            return False

        if self.user_group is not None:
            groups = get_user_groups(username)
            if self.user_group not in groups and (
                    self.admin_group is None or self.admin_group not in groups):
                valid.error('Username or password incorrect')
                return False

        return True

    def prepare_user(self, username: str) -> User:
        user = get_user(username)

        if user is None:
            assert self.create_users, \
                "tried to call prepare_user for an user that doesn't exist, " \
                "and create_users is false"
            user = self.create(username)

        if self.admin_group is not None:
            user_groups = os.getgrouplist(user.username,
                                          pwd.getpwnam(user.username).pw_gid)

            should_be_admin = False
            if self.admin_group in user_groups:
                should_be_admin = True

            is_admin = user.user_type == UserType.admin

            if should_be_admin and not is_admin:
                user.user_type = UserType.admin
                db.session.commit()
            elif not should_be_admin and is_admin:
                user.user_type = UserType.active_non_paying
                db.session.commit()

        return user

    def create(self, username: str) -> User:
        user = User(username)
        user.email = f'{username}@{self.domain}'
        user.password = ''
        user.invites = cfg("meta.sr.ht::settings", "user-invites", default=0)

        user.confirmation_hash = None
        user.user_type = UserType.active_non_paying

        db.session.add(user)
        db.session.commit()

        audit_log("account created", user=user)

        return user


def get_user_groups(username: str) -> [int]:
    return os.getgrouplist(username, pwd.getpwnam(username).pw_gid)

A metasrht/auth_validation.py => metasrht/auth_validation.py +57 -0
@@ 0,0 1,57 @@
import re

from jinja2 import Markup
from srht.config import cfg
from zxcvbn import zxcvbn

from metasrht.blacklist import email_blacklist, username_blacklist
from metasrht.types import User


def validate_username(valid, username):
    user = User.query.filter(User.username == username).first()
    valid.expect(user is None, "This username is already in use.", "username")
    valid.expect(2 <= len(username) <= 30,
                 "Username must contain between 2 and 30 characters.",
                 "username")
    valid.expect(re.match("^[a-z_]", username),
                 "Username must start with a lowercase letter or underscore.",
                 "username")
    valid.expect(re.match("^[a-z0-9_-]+$", username),
                 "Username may contain only lowercase letters, numbers, "
                 "hyphens and underscores", "username")
    valid.expect(username not in username_blacklist,
                 "This username is not available", "username")


def validate_email(valid, email):
    user = User.query.filter(User.email == email).first()
    valid.expect(user is None, "This email address is already in use.", "email")
    valid.expect(len(email) <= 256,
                 "Email must be no more than 256 characters.", "email")
    valid.expect("@" in email, "This is not a valid email address.", "email")
    if valid.ok:
        [user, domain] = email.split("@")
        valid.expect(not any([domain.endswith(bld) for bld in email_blacklist]),
                     "This email domain is blacklisted. Disposable email "
                     "addresses are prohibited by the terms of service - we "
                     "must be able to reach you at your account's primary "
                     "email address. Contact support if you believe this "
                     "domain was blacklisted in error.", "email")


def validate_password(valid, password):
    valid.expect(len(password) <= 512,
                 "Password must be less than 512 characters.", "password")

    if cfg("sr.ht", "environment", default="production") == "development":
        return
    strength = zxcvbn(password)
    time = \
        strength["crack_times_display"]["offline_slow_hashing_1e4_per_second"]
    valid.expect(strength["score"] >= 3, Markup(
        "This password is too weak &mdash; it could be cracked in " +
        f"{time} if our database were broken into. Try using " +
        "a few words instead of random letters and symbols. A " +
        "<a href='https://www.passwordstore.org/'>password manager</a> " +
        "is strongly recommended."), field="password")

M metasrht/blueprints/auth.py => metasrht/blueprints/auth.py +29 -71
@@ 1,24 1,26 @@
from datetime import datetime
from urllib.parse import urlparse

from flask import Blueprint, render_template, abort, request, redirect
from flask import url_for
from jinja2 import Markup
from metasrht.audit import audit_log
from metasrht.auth import hash_password, check_password
from metasrht.blacklist import email_blacklist, username_blacklist
from metasrht.email import send_email
from metasrht.totp import totp
from metasrht.types import User, UserType, Invite
from metasrht.types import UserAuthFactor, FactorType
from metasrht.webhooks import UserWebhook
from prometheus_client import Counter
from srht.config import cfg, get_global_domain
from srht.database import db
from srht.flask import csrf_bypass, session
from srht.oauth import current_user, login_user, logout_user
from srht.validation import Validation
from urllib.parse import urlparse
from zxcvbn import zxcvbn
import re

from metasrht.audit import audit_log
from metasrht.auth import allow_registration, user_valid, prepare_user, \
    is_external_auth
from metasrht.auth.builtin import hash_password
from metasrht.auth_validation import validate_username, validate_email, \
    validate_password
from metasrht.email import send_email
from metasrht.totp import totp
from metasrht.types import User, UserType, Invite
from metasrht.types import UserAuthFactor, FactorType
from metasrht.webhooks import UserWebhook

auth = Blueprint('auth', __name__)



@@ 52,71 54,30 @@ def validate_return_url(return_to):
def index():
    if current_user:
        return redirect(url_for("profile.profile_GET"))
    is_open = cfg("meta.sr.ht::settings", "registration") == "yes"
    return render_template("index.html", is_open=is_open)
    return render_template("index.html")

@auth.route("/register")
def register():
    if current_user:
        return redirect("/")
    is_open = cfg("meta.sr.ht::settings", "registration") == "yes"
    return render_template("register.html", is_open=is_open)
    return render_template("register.html")

@auth.route("/register/<invite_hash>")
def register_invite(invite_hash):
    if current_user:
        return redirect("/")

    if is_external_auth():
        return render_template("register.html")

    invite = (Invite.query
        .filter(Invite.invite_hash == invite_hash)
        .filter(Invite.recipient_id == None)
    ).one_or_none()
    if not invite:
        abort(404)
    return render_template("register.html",
            is_open=True, invite_hash=invite_hash)

def validate_username(valid, username):
    user = User.query.filter(User.username == username).first()
    valid.expect(user is None, "This username is already in use.", "username")
    valid.expect(2 <= len(username) <= 30,
            "Username must contain between 2 and 30 characters.", "username")
    valid.expect(re.match("^[a-z_]", username),
            "Username must start with a lowercase letter or underscore.",
            "username")
    valid.expect(re.match("^[a-z0-9_-]+$", username),
            "Username may contain only lowercase letters, numbers, "
            "hyphens and underscores", "username")
    valid.expect(username not in username_blacklist,
            "This username is not available", "username")

def validate_email(valid, email):
    user = User.query.filter(User.email == email).first()
    valid.expect(user is None, "This email address is already in use.", "email")
    valid.expect(len(email) <= 256,
            "Email must be no more than 256 characters.", "email")
    valid.expect("@" in email, "This is not a valid email address.", "email")
    if valid.ok:
        [user, domain] = email.split("@")
        valid.expect(not any([domain.endswith(bld) for bld in email_blacklist]),
            "This email domain is blacklisted. Disposable email addresses are " +
            "prohibited by the terms of service - we must be able to reach you " +
            "at your account's primary email address. Contact support if you " +
            "believe this domain was blacklisted in error.", "email")

def validate_password(valid, password):
    valid.expect(len(password) <= 512,
            "Password must be less than 512 characters.", "password")

    if cfg("sr.ht", "environment", default="production") == "development":
        return
    strength = zxcvbn(password)
    time = strength["crack_times_display"]["offline_slow_hashing_1e4_per_second"]
    valid.expect(strength["score"] >= 3, Markup(
            "This password is too weak &mdash; it could be cracked in " +
            f"{time} if our database were broken into. Try using " +
            "a few words instead of random letters and symbols. A " +
            "<a href='https://www.passwordstore.org/'>password manager</a> " +
            "is strongly recommended."), field="password")
    return render_template("register.html", invite_hash=invite_hash)


@csrf_bypass # for registration via sourcehut.org
@auth.route("/register", methods=["POST"])


@@ 137,7 98,7 @@ def register_POST():
            return redirect("/registered")

    valid = Validation(request)
    is_open = cfg("meta.sr.ht::settings", "registration") == "yes"
    is_open = allow_registration()

    username = valid.require("username", friendly_name="Username")
    email = valid.require("email", friendly_name="Email address")


@@ 257,15 218,7 @@ def login_POST():
    if not valid.ok:
        return render_template("login.html", valid=valid), 400

    user = User.query.filter(
        (User.username == username.lower()) |
        (User.email == username.strip())).one_or_none()

    valid.expect(user is not None, "Username or password incorrect")

    if valid.ok:
        valid.expect(check_password(password, user.password),
                "Username or password incorrect")
    user_valid(valid, username, password)

    if not valid.ok:
        metrics.meta_logins_failed.inc()


@@ 274,6 227,8 @@ def login_POST():
            username=username,
            valid=valid)

    user = prepare_user(username)

    factors = UserAuthFactor.query \
        .filter(UserAuthFactor.user_id == user.id).all()



@@ 368,6 323,9 @@ def forgot():

@auth.route("/forgot", methods=["POST"])
def forgot_POST():
    if is_external_auth():
        abort(403)

    valid = Validation(request)
    email = valid.require("email", friendly_name="Email")
    if not valid.ok:

M metasrht/blueprints/users.py => metasrht/blueprints/users.py +5 -2
@@ 1,13 1,16 @@
from datetime import datetime, timedelta

from flask import Blueprint, render_template, request, redirect, url_for, abort
from sqlalchemy import and_
from srht.database import db
from srht.flask import paginate_query
from srht.oauth import UserType
from srht.search import search_by
from srht.validation import Validation
from sqlalchemy import and_

from metasrht.decorators import adminrequired
from metasrht.types import User, UserAuthFactor, FactorType, AuditLogEntry, Invoice
from metasrht.types import User, UserAuthFactor, FactorType, AuditLogEntry, \
    Invoice
from metasrht.types import UserNote, PaymentInterval
from metasrht.webhooks import UserWebhook


M metasrht/templates/forgot.html => metasrht/templates/forgot.html +5 -1
@@ 12,7 12,11 @@
</div>
<div class="row">
  <div class="col-md-6">
    {% if done %}
    {% if is_external_auth() %}
    Password reset is disabled because sr.ht authentication is managed by a
    different service. Please contact the system administrator for further
    information on how to reset your password.
    {% elif done %}
    <p>
      An email has been sent to your account with a link to reset your password.
    </p>

M metasrht/templates/register.html => metasrht/templates/register.html +5 -1
@@ 13,7 13,11 @@
    </h3>
  </div>
</div>
{% if is_open %}
{% if is_external_auth() %}
<p>Registration is disabled because sr.ht authentication is managed by a
  different service. Please contact the system administrator for further
  information.</p>
{% elif allow_registration() %}
<div class="row">
  <div class="col-md-10 offset-md-1">
    {% include "blurb.html" %}

M metasrht/templates/security.html => metasrht/templates/security.html +5 -0
@@ 37,6 37,7 @@
  </section>
  <section class="col-md-12">
    <h3>Reset your password</h3>
    {% if not is_external_auth() %}
    <form method="POST" action="/forgot">
      {{csrf_token()}}
      <input type="hidden" name="email" value="{{current_user.email}}" />


@@ 45,6 46,10 @@
        {{icon('caret-right')}}
      </button>
    </form>
    {% else %}
    Password reset is disabled because sr.ht authentication is managed by a
    different service.
    {% endif %}
  </section>
  <section class="col-md-12">
    <h3>Audit Log</h3>

M metasrht/templates/tabs.html => metasrht/templates/tabs.html +1 -1
@@ 28,7 28,7 @@
  {% endif %}
</li>
{% endif %}
{% if cfg("meta.sr.ht::settings", "registration") != "yes" %}
{% if allow_registration() %}
<li class="nav-item invite-tab">
  {{ link("/invites", "invites ({})".format(current_user.invites)) }}
</li>

M setup.py => setup.py +9 -4
@@ 1,9 1,10 @@
#!/usr/bin/env python3
from setuptools import setup
import subprocess
import importlib.resources
import os
import subprocess
import sys
import importlib.resources

from setuptools import setup

with importlib.resources.path('srht', 'Makefile') as f:
    srht_path = f.parent.as_posix()


@@ 21,6 22,7 @@ setup(
  packages = [
      'metasrht',
      'metasrht.types',
      'metasrht.auth',
      'metasrht.blueprints',
      'metasrht.blueprints.api',
      'metasrht.alembic',


@@ 45,6 47,9 @@ setup(
      'weasyprint',
      'zxcvbn'
  ],
  extras_require = {
      'unix-pam-auth': ['python_pam'],
  },
  license = 'AGPL-3.0',
  package_data={
      'metasrht': [


@@ 57,10 62,10 @@ setup(
      ]
  },
  scripts = [
      'metasrht-createuser',
      'metasrht-daily',
      'metasrht-initdb',
      'metasrht-invoicestats',
      'metasrht-manageuser',
      'metasrht-migrate',
  ]
)