Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First working API implementation #51

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
524feac
First working API implementation
jameswthorne Jan 12, 2017
bf93da4
Applied suggested fixes from jparise
jameswthorne Jan 13, 2017
7fc18ec
Deprecate support for py26 and py34
May 6, 2018
b8ce29e
Merge pull request #78 from pinterest/deprectate-py26-py34
nichochar May 6, 2018
331d421
Mock redis in tests using mockredis
May 6, 2018
2e0a296
Remove -s flag in tests
May 6, 2018
f59fd33
Remove 2.6 and 3.3 from .travis.yml
May 6, 2018
5a0beb1
Quote url to fix equal sign breaking outlook clients
May 6, 2018
9e7ca47
py2 and py3 support
May 6, 2018
13f294c
Use werkzeug quote/unquote functions instead of urllib
May 7, 2018
75b6a69
Make mock a requirement, not dev-requirement
May 7, 2018
68b4cec
Merge pull request #80 from pinterest/quoteurls
nichochar May 7, 2018
173f33f
Merge pull request #79 from pinterest/mock-redis
nichochar May 7, 2018
e45feb1
Bump version to 1.3.0
May 7, 2018
6fe4733
Merge pull request #81 from pinterest/bumpversion-1.3.0
nichochar May 7, 2018
699293b
Remove support for py26 and py33 from readme
May 7, 2018
548c998
Merge pull request #82 from pinterest/readme-update
nichochar May 7, 2018
e6eca0d
Use assertion methods introduced in Python 2.7
samueldg May 8, 2018
80f77a6
Fix assertEqual parameter order (expected, actual)
samueldg May 8, 2018
d407c26
Drop the dot in py.test (as recommended by pytest)
samueldg May 8, 2018
5ddecd4
Merge pull request #83 from samueldg/enhancement/modernize-tests
nichochar May 8, 2018
a2d4245
Add hiring plug in readme
May 12, 2018
a42815d
Merge pull request #84 from pinterest/shameless-hiring-plug
nichochar Jun 16, 2018
339dc0c
First working API implementation
jameswthorne Jan 12, 2017
23c3c81
Applied suggested fixes from jparise
jameswthorne Jan 13, 2017
7be6307
Rebased from master and incorporated suggested fixes
jameswthorne Jul 1, 2018
380af3c
Merge branch 'api' of https://github.com/jameswthorne/snappass into api
jameswthorne Jul 1, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ htmlcov/
.coverage
.coverage.*
.cache
.pytest_cache/

# virtualenv
venv/
Expand Down
2 changes: 0 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
language: python
python:
- "2.6"
- "2.7"
- "3.3"
- "3.4"
- "3.5"
- "3.6"
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
Version 1.3.0
-------------
* Quote urls to fix bug with ending in '='
* Mock redis
* Drop support for python 2.6 and python 3.3

Version 1.2.0
-------------
* Added Fernet cryptography to the stored keys, prevent access to full text passwords if someone has access to Redis
10 changes: 8 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ This means that even if someone has access to the Redis store, the passwords are
Requirements
------------

* Redis.
* Python 2.6, 2.7 or 3.3+.
* Redis
* Python 2.7+ or 3.4+ (both included)

Installation
------------
Expand Down Expand Up @@ -103,3 +103,9 @@ Alternatively, you can use `Docker`_ and `Docker Compose`_ to install and run Sn
$ docker-compose up -d

This will pull all dependencies, i.e. Redis and appropriate Python version (3.6), then start up SnapPass and Redis server. SnapPass server is accessible at: http://localhost:5000

We're Hiring!
-------------

Are you really excited about open-source and great software engineering?
Pinterest is [hiring](https://careers.pinterest.com/)!
5 changes: 4 additions & 1 deletion dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
pytest==3.5.1
pytest-cov==2.5.1
mockredispy==2.9.3
coverage==4.2
flake8==3.0.4
tox==2.3.1
tox==3.0.0
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ MarkupSafe==0.18
Werkzeug==0.9.4
itsdangerous==0.23
redis==2.8.0
cryptography==1.8.1
cryptography>=1.8.1
mock==1.0.1
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.2.0
current_version = 1.3.0
commit = True
tag = True
files = setup.py snappass/__init__.py
Expand Down
4 changes: 1 addition & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

setup(
name='snappass',
version='1.2.0',
version='1.3.0',
description="It's like SnapChat... for Passwords.",
long_description=(open('README.rst').read() + '\n\n' +
open('AUTHORS.rst').read()),
Expand All @@ -26,10 +26,8 @@
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
Expand Down
2 changes: 1 addition & 1 deletion snappass/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__author__ = 'davedash'
__version__ = '1.2.0'
__version__ = '1.3.0'
109 changes: 101 additions & 8 deletions snappass/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
import redis

from cryptography.fernet import Fernet
from flask import abort, Flask, render_template, request
from flask import abort, Flask, jsonify, make_response, render_template, request
from redis.exceptions import ConnectionError

from werkzeug.urls import url_quote_plus
from werkzeug.urls import url_unquote_plus

SNEAKY_USER_AGENTS = ('Slackbot', 'facebookexternalhit', 'Twitterbot',
'Facebot', 'WhatsApp', 'SkypeUriPreview',
Expand All @@ -25,7 +26,10 @@
app.config.update(
dict(STATIC_URL=os.environ.get('STATIC_URL', 'static')))

if os.environ.get('REDIS_URL'):
if os.environ.get('MOCK_REDIS'):
from mockredis import mock_strict_redis_client
redis_client = mock_strict_redis_client()
elif os.environ.get('REDIS_URL'):
redis_client = redis.StrictRedis.from_url(os.environ.get('REDIS_URL'))
else:
redis_host = os.environ.get('REDIS_HOST', 'localhost')
Expand Down Expand Up @@ -143,43 +147,132 @@ def clean_input():
return TIME_CONVERSION[time_period], request.form['password']


def make_base_url():
if NO_SSL:
base_url = request.url_root
else:
base_url = request.url_root.replace("http://", "https://")

return base_url

def request_is_valid(request):
"""
Ensure the request validates the following:
- not made by some specific User-Agents (to avoid chat's preview feature issue)
"""
return not SNEAKY_USER_AGENTS_RE.search(request.headers.get('User-Agent', ''))

def not_found_api():
message = {
'status': 404,
'message': 'Not Found: ' + request.url,
}

return make_response(jsonify(message), 404)

def unsupported_media_type_api():
message = {
'status': 415,
'message': 'Unsupported Media Type',
}

return make_response(jsonify(message), 415)

def bad_request_api():
message = {
'status': 400,
'message': 'Bad Request',
}

return make_response(jsonify(message), 400)


@app.route('/', methods=['GET'])
def index():
return render_template('set_password.html')


@app.route('/api', methods=['GET'])
def index_api():
base_url = make_base_url()

return "Generate a password share link with the following command: \n\n" \
'curl -X POST -H "Content-Type:application/json" -d \'{"password":"password-here","ttl":"week | day | hour"}\' ' + base_url + "api\n"


@app.route('/', methods=['POST'])
def handle_password():
ttl, password = clean_input()
token = set_password(password, ttl)

if NO_SSL:
base_url = request.url_root
else:
base_url = request.url_root.replace("http://", "https://")
link = base_url + token
base_url = make_base_url()

link = base_url + url_quote_plus(token)
return render_template('confirm.html', password_link=link)


@app.route('/api', methods=['POST'])
def handle_password_api():
if not request.headers['Content-Type'] == 'application/json':
return unsupported_media_type_api()

payload = request.get_json()

password = payload.get('password', None)

if not password:
return bad_request_api()

time_period = payload.get('ttl', 'week').lower()

if not time_period in TIME_CONVERSION:
return bad_request_api()

ttl = TIME_CONVERSION[time_period]

key = set_password(password, ttl)

base_url = make_base_url()

link_web = base_url + url_quote_plus(key)
link_api = base_url + "api/" + url_quote_plus(key)

data = {
'web' : link_web,
'api' : link_api,
}

return jsonify(data)


@app.route('/<password_key>', methods=['GET'])
def show_password(password_key):
if not request_is_valid(request):
abort(404)
password_key = url_unquote_plus(password_key)
password = get_password(password_key)
if not password:
abort(404)

return render_template('password.html', password=password)


@app.route('/api/<password_key>', methods=['GET'])
def get_password_api(password_key):
password_key = url_unquote_plus(password_key)
password = get_password(password_key)
if not password:
return not_found_api()

base_url = make_base_url()

data = {
'password' : password
}

return jsonify(data)


@check_redis_alive
def main():
app.run(host='0.0.0.0')
Expand Down
16 changes: 11 additions & 5 deletions tests.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from mock import patch
import time
import unittest
import uuid
from unittest import TestCase

from cryptography.fernet import Fernet
from werkzeug.exceptions import BadRequest
from mockredis import mock_strict_redis_client

# noinspection PyPep8Naming
import snappass.main as snappass
Expand All @@ -14,19 +16,20 @@

class SnapPassTestCase(TestCase):

@patch('redis.client.StrictRedis', mock_strict_redis_client)
def test_get_password(self):
password = "melatonin overdose 1337!$"
key = snappass.set_password(password, 30)
self.assertEqual(password, snappass.get_password(key))
# Assert that we can't look this up a second time.
self.assertEqual(None, snappass.get_password(key))
self.assertIsNone(snappass.get_password(key))

def test_password_is_not_stored_in_plaintext(self):
password = "trustno1"
token = snappass.set_password(password, 30)
redis_key = token.split(snappass.TOKEN_SEPARATOR)[0]
stored_password_text = snappass.redis_client.get(redis_key).decode('utf-8')
self.assertFalse(password in stored_password_text)
self.assertNotIn(password, stored_password_text)

def test_returned_token_format(self):
password = "trustsome1"
Expand Down Expand Up @@ -91,7 +94,10 @@ def test_password_after_expiration(self):
password = 'open sesame'
key = snappass.set_password(password, 1)
time.sleep(1.5)
self.assertEqual(None, snappass.get_password(key))
# Expire functionality must be explicitly invoked using do_expire(time).
# mockredis does not support automatic expiration at this time
snappass.redis_client.do_expire()
self.assertIsNone(snappass.get_password(key))


class SnapPassRoutesTestCase(TestCase):
Expand All @@ -104,7 +110,7 @@ def test_show_password(self):
password = "I like novelty kitten statues!"
key = snappass.set_password(password, 30)
rv = self.app.get('/{0}'.format(key))
self.assertTrue(password in rv.get_data(as_text=True))
self.assertIn(password, rv.get_data(as_text=True))

def test_bots_denial(self):
"""
Expand All @@ -125,7 +131,7 @@ def test_bots_denial(self):

for ua in a_few_sneaky_bots:
rv = self.app.get('/{0}'.format(key), headers={ 'User-Agent': ua })
self.assertEqual(rv.status_code, 404)
self.assertEqual(404, rv.status_code)


if __name__ == '__main__':
Expand Down
12 changes: 6 additions & 6 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[tox]
envlist = py26, py27, py33, py34, py35, py36, flake8
envlist = py27, py34, py35, py36, flake8

[testenv]
deps =
pytest
pytest-cov
setenv =
MOCK_REDIS = 1
commands =
pip install -r requirements.txt --use-wheel
py.test --junitxml=junit-{envname}.xml --cov-report xml tests.py
pip install -r requirements.txt
pip install -r dev-requirements.txt
pytest --junitxml=junit-{envname}.xml --cov-report xml tests.py

[testenv:flake8]
commands =
Expand Down