commit 8e169b1026a681273267ff9c9a686b1481cc6e57 Author: yackob03 Date: Fri Sep 20 11:55:44 2013 -0400 Index that kinda works and is backed by a database. Still lots to do. diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..47bbedd9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +venv +test.db diff --git a/app.py b/app.py new file mode 100644 index 000000000..9ea362887 --- /dev/null +++ b/app.py @@ -0,0 +1,10 @@ +from flask import Flask, make_response, jsonify + +app = Flask(__name__) + +@app.route('/_ping') +@app.route('/v1/_ping') +def ping(): + response = make_response('true', 200); + response.headers['X-Docker-Registry-Version'] = '0.6.0' + return response \ No newline at end of file diff --git a/auth.py b/auth.py new file mode 100644 index 000000000..e362c4c64 --- /dev/null +++ b/auth.py @@ -0,0 +1,99 @@ +import logging + +from functools import wraps +from werkzeug import LocalProxy +from flask import request, make_response, _request_ctx_stack +from base64 import b64decode + +import model + +from util import parse_namespace_repository + +logger = logging.getLogger(__name__) + + +def _get_user(): + return getattr(_request_ctx_stack.top, 'authenticated_user', None) + + +def _get_token(): + return getattr(_request_ctx_stack.top, 'validated_token', None) + + +authenticated_user = LocalProxy(_get_user) +validated_token = LocalProxy(_get_token) + + +def check_basic_auth(): + auth = request.headers.get('authorization', '') + normalized = [part.strip() for part in auth.split(' ') if part] + if normalized[0].lower() != 'basic' or len(normalized) != 2: + logger.debug('Invalid basic auth format.') + return False + + credentials = b64decode(normalized[1]).split(':') + + if len(credentials) != 2: + logger.debug('Invalid basic auth credential formet.') + + authenticated = model.verify_user(credentials[0], credentials[1]) + + if authenticated: + logger.debug('Successfully validated user: %s' % authenticated.username) + ctx = _request_ctx_stack.top + ctx.authenticated_user = authenticated + + return True + + # We weren't able to authenticate via basic auth. + return False + + +def check_token(): + auth = request.headers.get('authorization', '') + logger.debug('Validating auth token: %s' % auth) + + normalized = [part.strip() for part in auth.split(' ') if part] + if normalized[0].lower() != 'token' or len(normalized) != 3: + logger.debug('Invalid token format.') + return False + + token_details = normalized[2].split(',') + + if len(token_details) != 3: + logger.debug('Invalid token format.') + return False + + token_vals = {val[0]: val[1] for val in + (detail.split('=') for detail in token_details)} + if ('signature' not in token_vals or 'access' not in token_vals or + 'repository' not in token_vals): + logger.debug('Invalid token components.') + return False + + unquoted = token_vals['repository'][1:-1] + namespace, repository = parse_namespace_repository(unquoted) + logger.debug('Validing signature: %s' % token_vals['signature']) + validated = model.verify_token(namespace, repository, + token_vals['signature']) + + if validated: + logger.debug('Successfully validated token: %s' % validated.code) + ctx = _request_ctx_stack.top + ctx.validated_token = validated + + return True + + # WE weren't able to authenticate the token + logger.debug('Token could not be validated.') + return False + + +def requires_auth(f): + @wraps(f) + def wrapper(*args, **kwargs): + if not check_basic_auth() and not check_token(): + return make_response('Invalid credentials.', 401) + logger.debug('Successfully authenticated user or token.') + return f(*args, **kwargs) + return wrapper diff --git a/database.py b/database.py new file mode 100644 index 000000000..9aba27678 --- /dev/null +++ b/database.py @@ -0,0 +1,74 @@ +import string +from random import SystemRandom + +from peewee import * +from peewee import create_model_tables + +from datetime import datetime + +db = SqliteDatabase('test.db', threadlocals=True) + + +class BaseModel(Model): + class Meta: + database = db + + +class User(BaseModel): + username = CharField(primary_key=True) + password_hash = CharField() + email = CharField() + verified = BooleanField(default=False) + + +class Repository(BaseModel): + repository_id = PrimaryKeyField() + namespace = CharField() + name = CharField() + + class Meta: + database = db + indexes = ( + # create a unique index on namespace and name + (('namespace', 'name'), True), + ) + + +def random_string_generator(length=16): + def random_string(): + random = SystemRandom() + return ''.join([random.choice(string.ascii_uppercase + string.digits) + for x in range(length)]) + return random_string + + +class AccessToken(BaseModel): + token_id = PrimaryKeyField() + code = CharField(default=random_string_generator()) + repository = ForeignKeyField(Repository) + created = DateTimeField(default=datetime.now) + + +class Image(BaseModel): + image_id = CharField(primary_key=True) + checksum = CharField(null=True) + + +class RepositoryImage(BaseModel): + repository = ForeignKeyField(Repository) + image = ForeignKeyField(Image) + tag = CharField() + + class Meta: + database = db + indexes = ( + # we don't really want duplicates + (('repository', 'image', 'tag'), True), + ) + + +def create_tables(): + create_model_tables([User, Repository, Image, RepositoryImage, AccessToken]) + +if __name__ == '__main__': + create_tables() diff --git a/index.py b/index.py new file mode 100644 index 000000000..2ff583feb --- /dev/null +++ b/index.py @@ -0,0 +1,175 @@ +import json +import urllib +import json +import logging + +from flask import request, make_response, jsonify +from functools import wraps + +from app import app +from auth import requires_auth, authenticated_user, validated_token +from util import parse_namespace_repository + +import model + +logger = logging.getLogger(__name__) + + +REGISTRY_SERVER = 'localhost:5003' + + +def parse_repository_name(f): + @wraps(f) + def wrapper(repository, *args, **kwargs): + (namespace, repository) = parse_namespace_repository(repository) + return f(namespace, repository, *args, **kwargs) + return wrapper + + +def generate_headers(access): + def add_headers(f): + @wraps(f) + def wrapper(namespace, repository, *args, **kwargs): + response = f(namespace, repository, *args, **kwargs) + + response.headers['X-Docker-Endpoints'] = REGISTRY_SERVER + + if request.headers.get('X-Docker-Token', ''): + repo = model.create_or_fetch_repository(namespace, repository) + token = model.create_access_token(repo) + token_str = ('Token signature=%s,repository="%s/%s",access=%s' % + (token.code, namespace, repository, access)) + response.headers['WWW-Authenticate'] = token_str + response.headers['X-Docker-Token'] = token_str + + return response + return wrapper + return add_headers + + +@app.route('/v1/users', methods=['POST']) +@app.route('/v1/users/', methods=['POST']) +def create_user(): + user_data = request.get_json() + model.create_user(user_data['username'], user_data['password'], + user_data['email']) + return make_response('Created', 201) + + +@app.route('/v1/users', methods=['GET']) +@app.route('/v1/users/', methods=['GET']) +@requires_auth +def get_user(): + return jsonify({ + 'username': authenticated_user.username, + 'email': authenticated_user.email, + }) + + +@app.route('/v1/users//', methods=['PUT']) +@requires_auth +def update_user(username): + if authenticated_user.username != username: + return make_response('Forbidden', 403) + + update_request = request.get_json() + + if update_request.has_key('password'): + logger.debug('Updating user password.') + model.change_password(authenticated_user, update_request['password']) + + if update_request.has_key('email'): + logger.debug('Updating user email') + model.update_email(authenticated_user, update_request['email']) + + return jsonify({ + 'username': authenticated_user.username, + 'email': authenticated_user.email, + }) + + +@app.route('/v1/repositories/', methods=['PUT']) +@requires_auth +@parse_repository_name +@generate_headers(access='write') +def create_repository(namespace, repository): + image_descriptions = json.loads(request.data) + + repo = model.create_or_fetch_repository(namespace, repository) + + new_repo_images = {desc['id']: desc for desc in image_descriptions} + added_images = dict(new_repo_images) + for existing in model.get_repository_images(namespace, repository): + if existing.image_id in new_repo_images: + added_images.pop(existing.image_id) + else: + existing.repositoryimage.delete() + + for image_description in added_images.values(): + image = model.create_image(image_description['id']) + model.assign_image_repository(repo, image, image_description['Tag']) + + response = make_response('Created', 201) + return response + + +@app.route('/v1/repositories//images', methods=['PUT']) +@requires_auth +@parse_repository_name +@generate_headers(access='write') +def update_images(namespace, repository): + image_with_checksums = json.loads(request.data) + + for image in image_with_checksums: + model.set_image_checksum(image['id'], image['checksum']) + + return make_response('Updated', 204) + + +@app.route('/v1/repositories//images', methods=['GET']) +@requires_auth +@parse_repository_name +@generate_headers(access='read') +def get_repository_images(namespace, repository): + if validated_token and (validated_token.repository.name != repository or + validated_token.repository.namespace != namespace): + return make_response('Forbidden', 403) + + #TODO check user has permission for repository + + #TODO invalidate token + + all_images = [] + for image in model.get_repository_images(namespace, repository): + new_image_view = { + 'id': image.image_id, + 'tag': image.repositoryimage.tag, + 'checksum': image.checksum, + } + all_images.append(new_image_view) + + resp = make_response(json.dumps(all_images), 200) + resp.mimetype = 'application/json' + + repo = model.create_or_fetch_repository(namespace, repository) + + return resp + + +@app.route('/v1/repositories//images', methods=['DELETE']) +@requires_auth +@parse_repository_name +@generate_headers(access='delete') +def delete_repository_images(namespace, repository): + pass + + +@app.route('/v1/repositories//auth', methods=['PUT']) +@parse_repository_name +def put_repository_auth(namespace, repository): + pass + + +@app.route('/v1/search', methods=['GET']) +def get_search(): + pass \ No newline at end of file diff --git a/model.py b/model.py new file mode 100644 index 000000000..1ba35c60a --- /dev/null +++ b/model.py @@ -0,0 +1,90 @@ +import bcrypt + +from database import User, Repository, Image, RepositoryImage, AccessToken + + +def create_user(username, password, email): + pw_hash = bcrypt.hashpw(password, bcrypt.gensalt()) + new_user = User.create(username=username, password_hash=pw_hash, + email=email) + return new_user + + +def verify_user(username, password): + try: + fetched = User.get(User.username == username) + except User.DoesNotExist: + return None + + if bcrypt.hashpw(password, fetched.password_hash) == fetched.password_hash: + return fetched + + # We weren't able to authorize the user + return None + + +def change_password(user, new_password): + pw_hash = bcrypt.hashpw(new_password, bcrypt.gensalt()) + user.password_hash = pw_hash + user.save() + + +def update_email(user, new_email): + user.email = new_email + user.verified = False + user.save() + + +def create_or_fetch_repository(namespace, name): + try: + repo = Repository.get(Repository.name == name and + Repository.namespace == namespace) + except Repository.DoesNotExist: + repo = Repository.create(namespace=namespace, name=name) + return repo + + +def create_image(image_id): + new_image = Image.create(image_id=image_id) + return new_image + + +def set_image_checksum(image_id, checksum): + fetched = Image.get(Image.image_id == image_id) + fetched.checksum = checksum + fetched.save() + return fetched + + +def assign_image_repository(repository, image, tag): + repo_image = RepositoryImage.create(repository=repository, image=image, + tag=tag) + return repo_image + + +def get_repository_images(namespace_name, repository_name): + select = Image.select(Image, RepositoryImage) + joined = select.join(RepositoryImage).join(Repository) + return joined.where(Repository.name == repository_name and + Repository.namespace == namespace_name) + + +def create_access_token(repository): + new_token = AccessToken.create(repository=repository) + return new_token + + +def verify_token(namespace_name, repository_name, code): + try: + fetched_repo = Repository.get(Repository.namespace == namespace_name and + Repository.name == repository_name) + except Repository.DoesNotExist: + return None + + try: + fetched = AccessToken.get(AccessToken.code == code and + AccessToken.repository == fetched_repo) + except AccessToken.DoesNotExist: + return None + + return fetched diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..6bf2e9686 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +peewee +flask +py-bcrypt \ No newline at end of file diff --git a/util.py b/util.py new file mode 100644 index 000000000..7575b7726 --- /dev/null +++ b/util.py @@ -0,0 +1,12 @@ +import urllib + + +def parse_namespace_repository(repository): + parts = repository.rstrip('/').split('/', 1) + if len(parts) < 2: + namespace = 'library' + repository = parts[0] + else: + (namespace, repository) = parts + repository = urllib.quote_plus(repository) + return (namespace, repository) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 000000000..7762c1ae5 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,11 @@ +import logging + +from app import app + +import index + +if __name__ == '__main__': + FORMAT = '%(asctime)-15s - %(levelname)s - %(pathname)s - %(funcName)s - %(message)s' + logging.basicConfig(format=FORMAT, level=logging.DEBUG) + + app.run(port=5002, debug=True)