(#1834) Added login page as an optional authentication method (thnx to 2mny for the Proof of concept & help)

This commit is contained in:
evilhero 2018-01-14 22:28:42 -05:00
parent ad74051985
commit a2f3fb7d67
9 changed files with 368 additions and 19 deletions

View File

@ -240,6 +240,8 @@ def main():
'https_chain': mylar.CONFIG.HTTPS_CHAIN,
'http_username': mylar.CONFIG.HTTP_USERNAME,
'http_password': mylar.CONFIG.HTTP_PASSWORD,
'authentication': mylar.CONFIG.AUTHENTICATION,
'login_timeout': mylar.CONFIG.LOGIN_TIMEOUT,
'opds_enable': mylar.CONFIG.OPDS_ENABLE,
'opds_authentication': mylar.CONFIG.OPDS_AUTHENTICATION,
'opds_username': mylar.CONFIG.OPDS_USERNAME,

88
data/css/login.css Executable file
View File

@ -0,0 +1,88 @@
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
src: local('Lato Regular'), local('Lato-Regular'), url(http://themes.googleusercontent.com/static/fonts/lato/v7/qIIYRU-oROkIk8vfvxw6QvesZW2xOQ-xsNqO47m55DA.woff) format('woff');
}
body {
background: #808080 ;
font-family: "Lato" ;
}
.wrap {
width:250px;
height: auto;
margin: auto;
margin-top: 10%;
}
.avatar {
width: 100%;
margin: auto;
width: 65px;
border-radius: 100px;
height: 65px;
background: #808080 ;
position: relative;
bottom: -15px;
}
.avatar img {
width: 55px;
height: 55px;
border-radius: 100px;
margin: auto;
border:3px solid #fff;
display: block;
}
.wrap input {
border: none;
background: #fff;
font-family:Lato ;
font-weight:700 ;
display: block;
height: 40px;
outline: none;
width: calc(100% - 24px) ;
margin: auto;
padding: 6px 12px 6px 12px;
}
.bar {
width: 100%;
height: 1px;
background: #fff ;
}
.bar i {
width: 95%;
margin: auto;
height: 1px ;
display: block;
background: #d1d1d1;
}
.wrap input[type="text"] {
border-radius: 7px 7px 0px 0px ;
}
.wrap input[type="password"] {
border-radius: 0px 0px 7px 7px ;
}
.forgot_link {
color: #83afdf;
text-decoration: none;
font-size: 11px;
position: relative;
left: 193px;
top: -36px;
}
.wrap button {
width: 100%;
border-radius: 7px;
background: #666666;
text-decoration: center;
border: none;
color: #ffffff;
margin-top:-5px;
padding-top: 14px;
padding-bottom: 14px;
outline: none;
font-size: 13px;
border-bottom: 3px solid #404040;
cursor: pointer;
}

View File

@ -92,6 +92,7 @@
<small>
<a href="shutdown"><span class="ui-button-icon-primary ui-icon ui-icon-power"></span>Shutdown</a> |
<a href="restart"><span class="ui-button-icon-primary ui-icon ui-icon-power"></span>Restart</a> |
<a href="/auth/logout"><span class="ui-button-icon-primary ui-icon ui-icon-power"></span>Logout</a></br>
<a href="#" onclick="doAjaxCall('checkGithub',$(this))" data-success="Checking for update successful" data-error="Error checking for update"><span class="ui-icon ui-icon-refresh"></span>Check for new version</a>
</small>
</div>

View File

@ -143,16 +143,38 @@
<input type="text" title="the FULL path to the key" name="https_key" value="${config['https_key']}" size="30">
</div>
</div>
</div>
<div class="row">
<label>HTTP Username</label>
<input type="text" name="http_username" value="${config['http_user']}" size="30">
</div>
<div class="row">
<label>HTTP Password</label>
<input type="password" name="http_password" value="${config['http_pass']| h}" size="30">
</div>
</div>
<div class="row">
<label>Authentication</label>
<select name="authentication" onchange="authoptions()" id="authentication">
%for lpe in [0, 1, 2]:
<%
if config['authentication'] == lpe:
outputselect = 'selected'
else:
outputselect = ''
if lpe == 2:
auth_value = 'Forms (Login Page)'
elif lpe == 1:
auth_value = 'Basic (Browser Popup)'
else:
auth_value = 'None'
%>
<option value=${lpe} ${outputselect}>${auth_value}</option>
%endfor
</select>
</div>
<div id="auth_options">
<div class="row">
<label>HTTP Username</label>
<input type="text" name="http_username" value="${config['http_user']}" size="30">
</div>
<div class="row">
<label>HTTP Password</label>
<input type="password" name="http_password" value="${config['http_pass']| h}" size="30">
</div>
</div>
<div class="row checkbox">
<input type="checkbox" name="launch_browser" value="1" ${config['launch_browser']} /> <label>Launch Browser on Startup</label>
</div>
@ -1390,7 +1412,17 @@
}
}
</script>
<script>
function authoptions()
{
var answer = document.getElementById("authentication");
if(answer[answer.selectedIndex].value == 2 || answer[answer.selectedIndex].value == 1){
document.getElementById("auth_options").style.display = "initial";
} else {
document.getElementById("auth_options").style.display = "none";
}
}
</script>
<script>
function initThisPage()

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en" >
<head>
<meta charset="UTF-8">
<title>Mylar</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css">
<link rel="stylesheet" href="/css/login.css">
</head>
<body>
<form action="/auth/login">
<div class="wrap">
<div class="avatar">
<img src="/images/mylarlogo.png" />
</div>
<input type="text" placeholder="username" name="username" required autofocus />
<div class="bar">
<i></i>
</div>
<input type="password" placeholder="password" name="password" required />
<button>Sign in</button>
</div>
</form>
</body>
</html>

151
mylar/auth.py Executable file
View File

@ -0,0 +1,151 @@
#!/usr/bin/env python
# -*- encoding: UTF-8 -*-
# This file is part of Mylar.
#
# Mylar is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Mylar is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Mylar. If not, see <http://www.gnu.org/licenses/>.
#
######
# Form based authentication for CherryPy. Requires the
# Session tool to be loaded.
###### from cherrypy/tools on github
import cherrypy
from cherrypy.lib.static import serve_file
from cgi import escape
import urllib
SESSION_KEY = '_cp_username'
def check_credentials(username, password):
"""Verifies credentials for username and password.
Returns None on success or a string describing the error on failure"""
# Adapt to your needs
forms_user = cherrypy.request.config['auth.forms_username']
forms_pass = cherrypy.request.config['auth.forms_password']
if username == forms_user and password == forms_pass:
return None
else:
return u"Incorrect username or password."
def check_auth(*args, **kwargs):
"""A tool that looks in config for 'auth.require'. If found and it
is not None, a login is required and the entry is evaluated as a list of
conditions that the user must fulfill"""
conditions = cherrypy.request.config.get('auth.require', None)
get_parmas = urllib.quote(cherrypy.request.request_line.split()[1])
if conditions is not None:
username = cherrypy.session.get(SESSION_KEY)
if username:
cherrypy.request.login = username
for condition in conditions:
# A condition is just a callable that returns true or false
if not condition():
raise cherrypy.HTTPRedirect("/auth/login")
else:
# Send old page as from_page parameter
raise cherrypy.HTTPRedirect("/auth/login")
cherrypy.tools.auth = cherrypy.Tool('before_handler', check_auth)
def require(*conditions):
"""A decorator that appends conditions to the auth.require config
variable."""
def decorate(f):
if not hasattr(f, '_cp_config'):
f._cp_config = dict()
if 'auth.require' not in f._cp_config:
f._cp_config['auth.require'] = []
f._cp_config['auth.require'].extend(conditions)
return f
return decorate
# Conditions are callables that return True
# if the user fulfills the conditions they define, False otherwise
#
# They can access the current username as cherrypy.request.login
#
# Define those at will however suits the application.
def member_of(groupname):
def check():
# replace with actual check if <username> is in <groupname>
return cherrypy.request.login == 'joe' and groupname == 'admin'
return check
def name_is(reqd_username):
return lambda: reqd_username == cherrypy.request.login
# These might be handy
def any_of(*conditions):
"""Returns True if any of the conditions match"""
def check():
for c in conditions:
if c():
return True
return False
return check
# By default all conditions are required, but this might still be
# needed if you want to use it inside of an any_of(...) condition
def all_of(*conditions):
"""Returns True if all of the conditions match"""
def check():
for c in conditions:
if not c():
return False
return True
return check
# Controller to provide login and logout actions
class AuthController(object):
def on_login(self, username):
"""Called on successful login"""
# not needed or used for Mylar currently
def on_logout(self, username):
"""Called on logout"""
# not needed or used for Mylar currently
def get_loginform(self, username, msg="Enter login information"):
from mylar.webserve import serve_template
username=escape(username, True)
return serve_template(templatename="login.html", title="Login")
@cherrypy.expose
def login(self, username=None, password=None):
if username is None or password is None:
return self.get_loginform("")
error_msg = check_credentials(username, password)
if error_msg:
return self.get_loginform(username, error_msg)
else:
cherrypy.session.regenerate()
cherrypy.session[SESSION_KEY] = cherrypy.request.login = username
self.on_login(username)
raise cherrypy.HTTPRedirect("/")
@cherrypy.expose
def logout(self):
sess = cherrypy.session
username = sess.get(SESSION_KEY, None)
sess[SESSION_KEY] = None
if username:
cherrypy.request.login = None
self.on_logout(username)
raise cherrypy.HTTPRedirect("/")

View File

@ -95,6 +95,8 @@ _CONFIG_DEFINITIONS = OrderedDict({
'HTTPS_CHAIN' : (str, 'Interface', None),
'HTTPS_FORCE_ON' : (bool, 'Interface', False),
'HOST_RETURN' : (str, 'Interface', None),
'AUTHENTICATION' : (int, 'Interface', 0),
'LOGIN_TIMEOUT': (int, 'Interface', 43800),
'API_ENABLED' : (bool, 'API', False),
'API_KEY' : (str, 'API', None),
@ -727,6 +729,12 @@ class Config(object):
if self.BLACKLISTED_PUBLISHERS is not None and type(self.BLACKLISTED_PUBLISHERS) == unicode:
setattr(self, 'BLACKLISTED_PUBLISHERS', self.BLACKLISTED_PUBLISHERS.split(', '))
if all([self.AUTHENTICATION == 0, self.HTTP_USERNAME is not None, self.HTTP_PASSWORD is not None]):
#set it to the default login prompt if nothing selected.
self.AUTHENTICATION = 1
elif all([self.HTTP_USERNAME is None, self.HTTP_PASSWORD is None]):
self.AUTHENTICATION = 0
#comictagger - force to use included version if option is enabled.
if self.ENABLE_META:
mylar.CMTAGGER_PATH = mylar.PROG_DIR

View File

@ -39,6 +39,7 @@ import shutil
import mylar
from mylar import logger, db, importer, mb, search, filechecker, helpers, updater, parseit, weeklypull, PostProcessor, librarysync, moveit, Failed, readinglist, notifiers, sabparse, config
from mylar.auth import AuthController, require
import simplejson as simplejson
@ -66,6 +67,9 @@ class WebInterface(object):
# _cp_config = { 'tools.filter_reqeust_on': True }
# login and logout methods at /auth/login and /auth/logout
auth = AuthController()
def index(self):
if mylar.SAFESTART:
raise cherrypy.HTTPRedirect("manageComics")
@ -4233,6 +4237,7 @@ class WebInterface(object):
"enable_https": helpers.checked(mylar.CONFIG.ENABLE_HTTPS),
"https_cert": mylar.CONFIG.HTTPS_CERT,
"https_key": mylar.CONFIG.HTTPS_KEY,
"authentication": int(mylar.CONFIG.AUTHENTICATION),
"api_enabled": helpers.checked(mylar.CONFIG.API_ENABLED),
"api_key": mylar.CONFIG.API_KEY,
"launch_browser": helpers.checked(mylar.CONFIG.LAUNCH_BROWSER),

View File

@ -26,6 +26,7 @@ from mylar import logger
from mylar.webserve import WebInterface
from mylar.helpers import create_https_certificates
def initialize(options):
# HTTPS stuff stolen from sickbeard
@ -112,18 +113,46 @@ def initialize(options):
'/cache': {
'tools.staticdir.on': True,
'tools.staticdir.dir': mylar.CONFIG.CACHE_DIR,
'tools.auth_basic.on': False
'tools.auth_basic.on': False,
'tools.auth.on': False
}
}
if options['http_password'] is not None:
conf['/'].update({
'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'Mylar',
'tools.auth_basic.checkpassword': cherrypy.lib.auth_basic.checkpassword_dict(
{options['http_username']: options['http_password']})
})
conf['/api'] = {'tools.auth_basic.on': False}
if options['authentication'] == 2:
# Set up a sessions based login page instead of using basic auth,
# using the credentials set for basic auth. Attempting to browse to
# a restricted page without a session token will result in a
# redirect to the login page. A sucessful login should then redirect
# to the originally requested page.
#
# Login sessions timeout after 43800 minutes (1 month) unless
# changed in the config.
cherrypy.tools.sessions.timeout = options['login_timeout']
conf['/'].update({
'tools.sessions.on': True,
'tools.auth.on': True,
'auth.forms_username': options['http_username'],
'auth.forms_password': options['http_password'],
# Set all pages to require authentication.
# You can also set auth requirements on a per-method basis by
# using the @require() decorator on the methods in webserve.py
'auth.require': []
})
# exempt api, login page and static elements from authentication requirements
for i in ('/api', '/auth/login', '/css', '/images', '/js', 'favicon.ico'):
if i in conf:
conf[i].update({'tools.auth.on': False})
else:
conf[i] = {'tools.auth.on': False}
elif options['authentication'] == 1:
conf['/'].update({
'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'Mylar',
'tools.auth_basic.checkpassword': cherrypy.lib.auth_basic.checkpassword_dict(
{options['http_username']: options['http_password']})
})
conf['/api'] = {'tools.auth_basic.on': False}
if options['opds_authentication']:
user_list = {}