Tweaking a few things oauth things.

This commit is contained in:
Michael Lazar
2015-09-20 19:54:41 -07:00
parent 2d7937945c
commit 6cc744bf91
12 changed files with 244 additions and 314 deletions

View File

@@ -1,3 +1,3 @@
include version.py
include CHANGELOG.rst CONTRIBUTORS.rst LICENSE
include rtv/templates/*.html
include rtv/templates/*

View File

@@ -7,7 +7,7 @@ import logging
import requests
import praw
import praw.errors
from six.moves import configparser
import tornado
from . import config
from .exceptions import SubmissionError, SubredditError, SubscriptionError, ProgramError
@@ -18,70 +18,14 @@ from .docs import *
from .oauth import OAuthTool
from .__version__ import __version__
from tornado import ioloop
__all__ = []
def load_rtv_config():
"""
Attempt to load saved settings for things like the username and password.
"""
config = configparser.ConfigParser()
HOME = os.path.expanduser('~')
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME',
os.path.join(HOME, '.config'))
config_paths = [
os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'),
os.path.join(HOME, '.rtv')
]
# get the first existing config file
for config_path in config_paths:
if os.path.exists(config_path):
config.read(config_path)
break
defaults = {}
if config.has_section('rtv'):
defaults = dict(config.items('rtv'))
if 'ascii' in defaults:
defaults['ascii'] = config.getboolean('rtv', 'ascii')
return defaults
def load_oauth_config():
"""
Attempt to load saved OAuth settings
"""
config = configparser.ConfigParser()
HOME = os.path.expanduser('~')
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME',
os.path.join(HOME, '.config'))
if os.path.exists(os.path.join(XDG_CONFIG_HOME, 'rtv')):
config_path = os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg')
else:
config_path = os.path.join(HOME, '.rtv-oauth')
config.read(config_path)
if config.has_section('oauth'):
defaults = dict(config.items('oauth'))
else:
# Populate OAuth section
config.add_section('oauth')
config.set('oauth', 'auto_login', 'false')
with open(config_path, 'w') as cfg:
config.write(cfg)
defaults = dict(config.items('oauth'))
return defaults
# Pycharm debugging note:
# You can use pycharm to debug a curses application by launching rtv in a
# console window (python -m rtv) and using pycharm to attach to the remote
# process. On Ubuntu, you may need to allow ptrace permissions by setting
# ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf.
# http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails
def command_line():
@@ -90,59 +34,46 @@ def command_line():
epilog=CONTROLS + HELP,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('-s', dest='subreddit', help='subreddit name')
parser.add_argument('-l', dest='link', help='full link to a submission')
parser.add_argument('--ascii', action='store_true',
help='enable ascii-only mode')
parser.add_argument('--log', metavar='FILE', action='store',
help='Log HTTP requests')
group = parser.add_argument_group('authentication (optional)', AUTH)
group.add_argument('-u', dest='username', help='reddit username')
group.add_argument('-p', dest='password', help='reddit password')
oauth_group = parser.add_argument_group('OAuth data (optional)', OAUTH)
oauth_group.add_argument('--auto-login', dest='auto_login', help='OAuth auto-login setting')
oauth_group.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token')
parser.add_argument('-s', dest='subreddit', help='name of the subreddit that will be opened on start')
parser.add_argument('-l', dest='link', help='full URL of a submission that will be opened on start')
parser.add_argument('--ascii', action='store_true', help='enable ascii-only mode')
parser.add_argument('--log', metavar='FILE', action='store', help='log HTTP requests to a file')
parser.add_argument('--refresh-token', dest='refresh_token', help='OAuth refresh token')
parser.add_argument('--clear-session', dest='clear_session', action='store_true', help='Remove any saved OAuth tokens before starting')
args = parser.parse_args()
return args
def main():
"Main entry point"
# logging.basicConfig(level=logging.DEBUG, filename='rtv.log')
# Squelch SSL warnings
logging.captureWarnings(True)
locale.setlocale(locale.LC_ALL, '')
args = command_line()
local_rtv_config = load_rtv_config()
local_oauth_config = load_oauth_config()
# set the terminal title
# Set the terminal title
title = 'rtv {0}'.format(__version__)
if os.name == 'nt':
os.system('title {0}'.format(title))
else:
sys.stdout.write("\x1b]2;{0}\x07".format(title))
# Fill in empty arguments with config file values. Paramaters explicitly
# Fill in empty arguments with config file values. Parameters explicitly
# typed on the command line will take priority over config file params.
for key, val in local_rtv_config.items():
args = command_line()
local_config = config.load_config()
for key, val in local_config.items():
if getattr(args, key, None) is None:
setattr(args, key, val)
for k, v in local_oauth_config.items():
if getattr(args, k, None) is None:
setattr(args, k, v)
config.unicode = (not args.ascii)
# Squelch SSL warnings for Ubuntu
logging.captureWarnings(True)
if args.ascii:
config.unicode = False
if args.log:
logging.basicConfig(level=logging.DEBUG, filename=args.log)
if args.clear_session:
config.clear_refresh_token()
if args.refresh_token:
config.save_refresh_token(args.refresh_token)
try:
print('Connecting...')
@@ -150,21 +81,21 @@ def main():
reddit.config.decode_html_entities = False
with curses_session() as stdscr:
oauth = OAuthTool(reddit, stdscr, LoadScreen(stdscr))
if args.auto_login == 'true': # Ew!
if oauth.refresh_token:
oauth.authorize()
if args.link:
page = SubmissionPage(stdscr, reddit, oauth, url=args.link)
page.loop()
subreddit = args.subreddit or 'front'
page = SubredditPage(stdscr, reddit, oauth, subreddit)
page.loop()
except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken,
praw.errors.HTTPException) as e:
except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken):
print('Invalid OAuth data')
except requests.ConnectionError:
print('Connection timeout')
except requests.HTTPError:
except praw.errors.NotFound:
print('HTTP Error: 404 Not Found')
except praw.errors.HTTPException:
print('Connection timeout')
except SubmissionError as e:
print('Could not reach submission URL: {}'.format(e.url))
except SubredditError as e:
@@ -178,6 +109,6 @@ def main():
# Ensure sockets are closed to prevent a ResourceWarning
reddit.handler.http.close()
# Explicitly close file descriptors opened by Tornado's IOLoop
ioloop.IOLoop.current().close(all_fds=True)
tornado.ioloop.IOLoop.current().close(all_fds=True)
sys.exit(main())

View File

@@ -1,14 +1,58 @@
"""
Global configuration settings
"""
import os
from six.moves import configparser
HOME = os.path.expanduser('~')
XDG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
CONFIG = os.path.join(XDG_HOME, 'rtv', 'rtv.cfg')
TOKEN = os.path.join(XDG_HOME, 'rtv', 'refresh-token')
unicode = True
"""
OAuth settings
"""
oauth_client_id = 'nxoobnwO7mCP5A'
# https://github.com/reddit/reddit/wiki/OAuth2
# Client ID is of type "installed app" and the secret should be left empty
oauth_client_id = 'E2oEtRQfdfAfNQ'
oauth_client_secret = 'praw_gapfill'
oauth_redirect_uri = 'http://127.0.0.1:65000/auth'
oauth_scope = 'edit-history-identity-mysubreddits-privatemessages-read-report-save-submit-subscribe-vote'
oauth_redirect_uri = 'http://127.0.0.1:65000/'
oauth_redirect_port = 65000
oauth_scope = ['edit', 'history', 'identity', 'mysubreddits', 'privatemessages',
'read', 'report', 'save', 'submit', 'subscribe', 'vote']
def load_config():
"""
Attempt to load settings from the local config file.
"""
config = configparser.ConfigParser()
if os.path.exists(CONFIG):
config.read(CONFIG)
config_dict = {}
if config.has_section('rtv'):
config_dict = dict(config.items('rtv'))
# Convert 'true'/'false' to boolean True/False
if 'ascii' in config_dict:
config_dict['ascii'] = config.getboolean('rtv', 'ascii')
if 'clear_session' in config_dict:
config_dict['clear_session'] = config.getboolean('rtv', 'clear_session')
if 'oauth_scope' in config_dict:
config_dict['oauth_scope'] = config.oauth_scope.split('-')
return config_dict
def load_refresh_token(filename=TOKEN):
if os.path.exists(filename):
with open(filename) as fp:
return fp.read().strip()
else:
return None
def save_refresh_token(token, filename=TOKEN):
with open(filename, 'w+') as fp:
fp.write(token)
def clear_refresh_token(filename=TOKEN):
if os.path.exists(filename):
os.remove(filename)

View File

@@ -1,6 +1,6 @@
from .__version__ import __version__
__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'OAUTH', 'CONTROLS', 'HELP', 'COMMENT_FILE',
__all__ = ['AGENT', 'SUMMARY', 'CONTROLS', 'HELP', 'COMMENT_FILE',
'SUBMISSION_FILE', 'COMMENT_EDIT_FILE']
AGENT = """\
@@ -12,16 +12,6 @@ Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a
terminal window.
"""
AUTH = """\
Authenticating is required to vote and leave comments. If only a username is
given, the program will display a secure prompt to enter a password.
"""
OAUTH = """\
Authentication is now done by OAuth, since PRAW will drop
password authentication soon.
"""
CONTROLS = """
Controls
--------

View File

@@ -102,21 +102,7 @@ def open_browser(url):
are not detected here.
"""
console_browsers = ['www-browser', 'links', 'links2', 'elinks', 'lynx', 'w3m']
display = bool(os.environ.get("DISPLAY"))
# Use the convention defined here to parse $BROWSER
# https://docs.python.org/2/library/webbrowser.html
if "BROWSER" in os.environ:
user_browser = os.environ["BROWSER"].split(os.pathsep)[0]
if user_browser in console_browsers:
display = False
if webbrowser._tryorder and webbrowser._tryorder[0] in console_browsers:
display = False
if display:
if check_browser_display():
command = "import webbrowser; webbrowser.open_new_tab('%s')" % url
args = [sys.executable, '-c', command]
with open(os.devnull, 'ab+', 0) as null:
@@ -127,6 +113,28 @@ def open_browser(url):
curses.doupdate()
def check_browser_display():
"""
Use a number of methods to guess if the default webbrowser will open in
the background as opposed to opening directly in the terminal.
"""
display = bool(os.environ.get("DISPLAY"))
# Use the convention defined here to parse $BROWSER
# https://docs.python.org/2/library/webbrowser.html
console_browsers = ['www-browser', 'links', 'links2', 'elinks', 'lynx', 'w3m']
if "BROWSER" in os.environ:
user_browser = os.environ["BROWSER"].split(os.pathsep)[0]
if user_browser in console_browsers:
display = False
if webbrowser._tryorder and webbrowser._tryorder[0] in console_browsers:
display = False
return display
def wrap_text(text, width):
"""
Wrap text paragraphs to the given character width while preserving newlines.

View File

@@ -1,185 +1,120 @@
import curses
import logging
import os
import time
import uuid
import webbrowser
import praw
from six.moves import configparser
from . import config
from .curses_helpers import show_notification, prompt_input
from tornado import gen, ioloop, web, httpserver
from concurrent.futures import ThreadPoolExecutor
__all__ = ['token_validity', 'OAuthTool']
_logger = logging.getLogger(__name__)
from . import config
from .curses_helpers import show_notification, prompt_input
from .helpers import check_browser_display, open_browser
__all__ = ['OAuthTool']
oauth_state = None
oauth_code = None
oauth_error = None
class HomeHandler(web.RequestHandler):
def get(self):
self.render('home.html')
class AuthHandler(web.RequestHandler):
def initialize(self):
self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx']
def get(self):
global oauth_state
global oauth_code
global oauth_error
global oauth_state, oauth_code, oauth_error
oauth_state = self.get_argument('state', default='state_placeholder')
oauth_code = self.get_argument('code', default='code_placeholder')
oauth_error = self.get_argument('error', default='error_placeholder')
oauth_state = self.get_argument('state', default='placeholder')
oauth_code = self.get_argument('code', default='placeholder')
oauth_error = self.get_argument('error', default='placeholder')
self.render('auth.html', state=oauth_state, code=oauth_code, error=oauth_error)
self.render('index.html', state=oauth_state, code=oauth_code, error=oauth_error)
# Stop IOLoop if using BackgroundBrowser (or GUI browser)
if not self.compact:
# Stop IOLoop if using a background browser such as firefox
if check_browser_display():
ioloop.IOLoop.current().stop()
class OAuthTool(object):
def __init__(self, reddit, stdscr=None, loader=None,
client_id=None, redirect_uri=None, scope=None):
def __init__(self, reddit, stdscr=None, loader=None):
self.reddit = reddit
self.stdscr = stdscr
self.loader = loader
self.config = configparser.ConfigParser()
self.config_fp = None
self.client_id = client_id or config.oauth_client_id
# Comply with PRAW's desperate need for client secret
self.client_secret = config.oauth_client_secret
self.redirect_uri = redirect_uri or config.oauth_redirect_uri
self.scope = scope or config.oauth_scope.split('-')
self.access_info = {}
# Terminal web browser
self.compact = os.environ.get('BROWSER') in ['w3m', 'links', 'elinks', 'lynx']
# Initialize Tornado webapp
self.callback_app = web.Application([
(r'/', HomeHandler),
(r'/auth', AuthHandler),
], template_path='rtv/templates')
self.http_server = None
def get_config_fp(self):
HOME = os.path.expanduser('~')
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME',
os.path.join(HOME, '.config'))
self.refresh_token = config.load_refresh_token()
if os.path.exists(os.path.join(XDG_CONFIG_HOME, 'rtv')):
file_path = os.path.join(XDG_CONFIG_HOME, 'rtv', 'oauth.cfg')
else:
file_path = os.path.join(HOME, '.rtv-oauth')
# Initialize Tornado webapp
routes = [('/', AuthHandler)]
self.callback_app = web.Application(routes, template_path='templates')
return file_path
self.reddit.set_oauth_app_info(config.oauth_client_id,
config.oauth_client_secret,
config.oauth_redirect_uri)
def open_config(self, update=False):
if self.config_fp is None:
self.config_fp = self.get_config_fp()
if update:
self.config.read(self.config_fp)
def save_config(self):
self.open_config()
with open(self.config_fp, 'w') as cfg:
self.config.write(cfg)
def clear_oauth_data(self):
self.open_config(update=True)
if self.config.has_section('oauth') and self.config.has_option('oauth', 'refresh_token'):
self.config.remove_option('oauth', 'refresh_token')
self.save_config()
@gen.coroutine
def open_terminal_browser(self, url):
with ThreadPoolExecutor(max_workers=1) as executor:
yield executor.submit(webbrowser.open_new_tab, url)
ioloop.IOLoop.current().stop()
# Reddit's mobile website works better on terminal browsers
if not check_browser_display():
if '.compact' not in self.reddit.config.API_PATHS['authorize']:
self.reddit.config.API_PATHS['authorize'] += '.compact'
def authorize(self):
if self.compact and not '.compact' in self.reddit.config.API_PATHS['authorize']:
self.reddit.config.API_PATHS['authorize'] += '.compact'
self.reddit.set_oauth_app_info(self.client_id,
self.client_secret,
self.redirect_uri)
self.open_config(update=True)
# If no previous OAuth data found, starting from scratch
if not self.config.has_section('oauth') or not self.config.has_option('oauth', 'refresh_token'):
if self.http_server is None:
self.http_server = httpserver.HTTPServer(self.callback_app)
self.http_server.listen(65000)
# Generate a random UUID
hex_uuid = uuid.uuid4().hex
permission_ask_page_link = self.reddit.get_authorize_url(str(hex_uuid),
scope=self.scope, refreshable=True)
if self.compact:
show_notification(self.stdscr, ['Opening ' + os.environ.get('BROWSER')])
curses.endwin()
ioloop.IOLoop.current().add_callback(self.open_terminal_browser, permission_ask_page_link)
ioloop.IOLoop.current().start()
curses.doupdate()
else:
with self.loader(message='Waiting for authorization'):
webbrowser.open(permission_ask_page_link)
ioloop.IOLoop.current().start()
global oauth_state
global oauth_code
global oauth_error
self.final_state = oauth_state
self.final_code = oauth_code
self.final_error = oauth_error
# Check if access was denied
if self.final_error == 'access_denied':
show_notification(self.stdscr, ['Declined access'])
return
elif self.final_error != 'error_placeholder':
show_notification(self.stdscr, ['Authentication error'])
return
# Check if UUID matches obtained state
# (if not, authorization process is compromised, and I'm giving up)
if hex_uuid != self.final_state:
show_notification(self.stdscr, ['UUID mismatch, stopping.'])
return
try:
with self.loader(message='Logging in'):
# Get access information (tokens and scopes)
self.access_info = self.reddit.get_access_information(self.final_code)
except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken) as e:
show_notification(self.stdscr, ['Invalid OAuth data'])
else:
if not self.config.has_section('oauth'):
self.config.add_section('oauth')
self.config.set('oauth', 'refresh_token', self.access_info['refresh_token'])
self.save_config()
# Otherwise, fetch new access token
else:
# If we already have a token, request new access credentials
if self.refresh_token:
with self.loader(message='Logging in'):
self.reddit.refresh_access_information(self.config.get('oauth', 'refresh_token'))
self.reddit.refresh_access_information(self.refresh_token)
return
# Start the authorization callback server
if self.http_server is None:
self.http_server = httpserver.HTTPServer(self.callback_app)
self.http_server.listen(config.oauth_redirect_port)
hex_uuid = uuid.uuid4().hex
authorize_url = self.reddit.get_authorize_url(
hex_uuid, scope=config.oauth_scope, refreshable=True)
# Open the browser and wait for the user to authorize the app
if check_browser_display():
with self.loader(message='Waiting for authorization'):
open_browser(authorize_url)
ioloop.IOLoop.current().start()
else:
with self.loader(delay=0, message='Redirecting to reddit'):
# Provide user feedback
time.sleep(1)
ioloop.IOLoop.current().add_callback(self._open_authorize_url,
authorize_url)
ioloop.IOLoop.current().start()
if oauth_error == 'access_denied':
show_notification(self.stdscr, ['Declined access'])
return
elif oauth_error != 'placeholder':
show_notification(self.stdscr, ['Authentication error'])
return
elif hex_uuid != oauth_state:
# Check if UUID matches obtained state.
# If not, authorization process is compromised.
show_notification(self.stdscr, ['UUID mismatch'])
return
try:
with self.loader(message='Logging in'):
access_info = self.reddit.get_access_information(oauth_code)
config.save_refresh_token(access_info['refresh_token'])
self.refresh_token = access_info['refresh_token']
except (praw.errors.OAuthAppRequired, praw.errors.OAuthInvalidToken):
show_notification(self.stdscr, ['Invalid OAuth data'])
else:
message = ['Welcome {}!'.format(self.reddit.user.name)]
show_notification(self.stdscr, message)
def clear_oauth_data(self):
self.reddit.clear_authentication()
config.clear_refresh_token()
self.refresh_token = None
@gen.coroutine
def _open_authorize_url(self, url):
with ThreadPoolExecutor(max_workers=1) as executor:
yield executor.submit(open_browser, url)
ioloop.IOLoop.current().stop()

View File

@@ -239,7 +239,7 @@ class BaseController(object):
class BasePage(object):
"""
Base terminal viewer incorperates a cursor to navigate content
Base terminal viewer incorporates a cursor to navigate content
"""
MIN_HEIGHT = 10
@@ -351,11 +351,10 @@ class BasePage(object):
"""
if self.reddit.is_oauth_session():
self.reddit.clear_authentication()
self.oauth.clear_oauth_data()
return
self.oauth.authorize()
show_notification(self.stdscr, ['Logged out'])
else:
self.oauth.authorize()
@BaseController.register('d')
def delete(self):

View File

@@ -24,7 +24,6 @@ class SubmissionPage(BasePage):
self.controller = SubmissionController(self)
self.loader = LoadScreen(stdscr)
self.oauth = oauth
if url:
content = SubmissionContent.from_url(reddit, url, self.loader)
elif submission:
@@ -32,8 +31,8 @@ class SubmissionPage(BasePage):
else:
raise ValueError('Must specify url or submission')
super(SubmissionPage, self).__init__(stdscr, reddit,
content, oauth, page_index=-1)
super(SubmissionPage, self).__init__(stdscr, reddit, content, oauth,
page_index=-1)
def loop(self):
"Main control loop"

View File

@@ -19,7 +19,6 @@ class SubscriptionPage(BasePage):
self.controller = SubscriptionController(self)
self.loader = LoadScreen(stdscr)
self.oauth = oauth
self.selected_subreddit_data = None
content = SubscriptionContent.from_user(reddit, self.loader)

View File

@@ -1,15 +0,0 @@
<!DOCTYPE html>
<title>RTV OAuth</title>
{% if error == 'access_denied' %}
<h3 style="color: red">Declined rtv access</h3>
<p>You chose to stop <span style="font-weight: bold">Reddit Terminal Viewer</span> from accessing your account, it will continue in unauthenticated mode.<br>
You can close this page.</p>
{% elif error != 'error_placeholder' %}
<h3 style="color: red">Error : {{ error }}</h3>
{% elif (state == 'state_placeholder' or code == 'code_placeholder') and error == 'error_placeholder' %}
<h3>Wait...</h3>
<p>This page is supposed to be a Reddit OAuth callback. You can't just come here hands in the pocket!</p>
{% else %}
<h3 style="color: green">Allowed rtv access</h3>
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> will now log in. You can close this page.</p>
{% end %}

View File

@@ -1,3 +0,0 @@
<!DOCTYPE html>
<title>OAuth helper</title>
<h1>Reddit Terminal Viewer OAuth helper</h1>

43
templates/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<title>RTV OAuth2 Helper</title>
<!-- style borrowed from http://bettermotherfuckingwebsite.com/ -->
<style type="text/css">
body {
margin:40px auto;
max-width:650px;
line-height:1.6;
font-size:18px;
font-family:Arial, Helvetica, sans-serif;
color:#444;
padding:0 10px;
}
h1, h2, h3 {
line-height:1.2
}
#footer {
position: absolute;
bottom: 0px;
width: 100%;
font-size:14px;
}
</style>
</head>
<body>
{% if error == 'access_denied' %}
<h1 style="color: red">Access Denied</h1><hr>
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> was denied access and will continue to operate in unauthenticated mode, you can close this window.
{% elif error != 'placeholder' %}
<h1 style="color: red">Error : {{ error }}</h1>
{% elif (state == 'placeholder' or code == 'placeholder') %}
<h1>Wait...</h1><hr>
<p>This page is supposed to be a Reddit OAuth callback. You can't just come here hands in your pocket!</p>
{% else %}
<h1 style="color: green">Access Granted</h1><hr>
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> will now log in, you can close this window.</p>
{% end %}
<div id="footer">View the <a href="http://www.github.com/michael-lazar/rtv">Documentation</a></div>
</body>
</html>