Add support for session expire at browser close.
Signed-off-by: Volker Theile <vtheile@suse.com>
import bcrypt
import cherrypy
-from ..tools import ApiController, RESTController
+from ..tools import ApiController, RESTController, Session
@ApiController('auth')
| | seconds without activity |
"""
- SESSION_KEY = '_username'
- SESSION_KEY_TS = '_username_ts'
-
- DEFAULT_SESSION_EXPIRE = 1200.0
-
@RESTController.args_from_json
- def create(self, username, password):
+ def create(self, username, password, stay_signed_in=False):
now = time.time()
config_username = self.mgr.get_localized_config('username', None)
config_password = self.mgr.get_localized_config('password', None)
config_password)
if username == config_username and hash_password == config_password:
cherrypy.session.regenerate()
- cherrypy.session[Auth.SESSION_KEY] = username
- cherrypy.session[Auth.SESSION_KEY_TS] = now
+ cherrypy.session[Session.USERNAME] = username
+ cherrypy.session[Session.TS] = now
+ cherrypy.session[Session.EXPIRE_AT_BROWSER_CLOSE] = not stay_signed_in
self.logger.debug('Login successful')
return {'username': username}
def bulk_delete(self):
self.logger.debug('Logout successful')
- cherrypy.session[Auth.SESSION_KEY] = None
- cherrypy.session[Auth.SESSION_KEY_TS] = None
+ cherrypy.session[Session.USERNAME] = None
+ cherrypy.session[Session.TS] = None
@staticmethod
def password_hash(password, salt_password=None):
@staticmethod
def check_auth():
- username = cherrypy.session.get(Auth.SESSION_KEY)
+ username = cherrypy.session.get(Session.USERNAME)
if not username:
Auth.logger.debug('Unauthorized access to {}'.format(cherrypy.url(
relative='server')))
'that resource')
now = time.time()
expires = float(Auth.mgr.get_localized_config(
- 'session-expire', Auth.DEFAULT_SESSION_EXPIRE))
+ 'session-expire', Session.DEFAULT_EXPIRE))
if expires > 0:
- username_ts = cherrypy.session.get(Auth.SESSION_KEY_TS, None)
+ username_ts = cherrypy.session.get(Session.TS, None)
if username_ts and float(username_ts) < (now - expires):
- cherrypy.session[Auth.SESSION_KEY] = None
- cherrypy.session[Auth.SESSION_KEY_TS] = None
+ cherrypy.session[Session.USERNAME] = None
+ cherrypy.session[Session.TS] = None
Auth.logger.debug('Session expired')
raise cherrypy.HTTPError(401,
'Session expired. You are not '
'authorized to access that resource')
- cherrypy.session[Auth.SESSION_KEY_TS] = now
+ cherrypy.session[Session.TS] = now
@staticmethod
def set_login_credentials(username, password):
*ngIf="(loginForm.submitted || password.dirty) && password.invalid">Password is required</div>
</div>
+ <!-- Stay signed in -->
+ <div class="checkbox">
+ <label translate>
+ <input id="stay_signed_in"
+ name="stay_signed_in"
+ type="checkbox"
+ [(ngModel)]="model.stay_signed_in">
+ Keep me logged in
+ </label>
+ </div>
+
<input type="submit"
class="btn btn-openattic btn-block"
[disabled]="loginForm.invalid"
export class Credentials {
username: string;
password: string;
+ stay_signed_in = false;
}
.login {
height: 100%;
}
-.login .utility-bar {
- position: absolute;
- margin: 0;
- padding: 0;
- top: 10px;
- right: 10px;
-}
-.login .utility-bar>a {
- color: #ececec;
- line-height: 1;
- padding: 10px 20px;
- position: relative;
- display: block;
- text-decoration: none;
-}
-.login .utility-bar>a:focus,
-.login .utility-bar>a:hover {
- color: #ffffff;
-}
-.login .utility-bar>a:hover {
- background-color: #505050;
-}
-.login .open>a,
-.login .open>a:hover,
-.login .open>a:focus {
- color: #ececec;
- border-color: transparent;
- background-color: transparent;
-}
-.login .utility-bar .dropdown-menu {
- left: auto;
- right: 0;
-}
-.login .icon-bar {
- position: absolute;
- margin: 0;
- padding: 0;
- bottom: 10px;
- left: 10px;
-}
-.login .icon-bar>a {
- color: #ececec;
- text-decoration: none;
-}
-.login .icon-bar>a .fa-dark {
- color: #474544;
-}
.login .row {
color: #ececec;
background-color: #474544;
margin-top: 0;
margin-bottom: 30px;
}
-.login .btn-password,
.login .form-control {
color: #ececec;
background-color: #555555;
}
-.login .col-lg-1, .login .col-lg-10, .login .col-lg-11, .login .col-lg-12, .login .col-lg-2, .login .col-lg-3,
-.login .col-lg-4, .login .col-lg-5, .login .col-lg-6, .login .col-lg-7, .login .col-lg-8, .login .col-lg-9 {
- padding-left: 35px;
- padding-right: 35px;
-}
-.login .col-md-1, .login .col-md-10, .login .col-md-11, .login .col-md-12, .login .col-md-2, .login .col-md-3,
-.login .col-md-4, .login .col-md-5, .login .col-md-6, .login .col-md-7, .login .col-md-8, .login .col-md-9 {
- padding-left: 30px;
- padding-right: 30px;
-}
-.login .col-sm-1, .login .col-sm-10, .login .col-sm-11, .login .col-sm-12, .login .col-sm-2, .login .col-sm-3,
-.login .col-sm-4, .login .col-sm-5, .login .col-sm-6, .login .col-sm-7, .login .col-sm-8, .login .col-sm-9 {
- padding-left: 25px;
- padding-right: 25px;
-}
-.login .col-xs-1, .col-xs-10, .login .col-xs-11, .login .col-xs-12, .login .col-xs-2, .login .col-xs-3,
-.login .col-xs-4, .login .col-xs-5, .login .col-xs-6, .login .col-xs-7, .login .col-xs-8, .login .col-xs-9 {
- padding-left: 15px;
- padding-right: 15px;
+.login .checkbox {
+ line-height: 20px;
}
/* Statistics */
],
"typeof-compare": true,
"unified-signatures": true,
- "variable-name": true,
+ "variable-name": [
+ true,
+ "check-format",
+ "allow-snake-case"
+ ],
"whitespace": [
true,
"check-branch",
from mgr_module import MgrModule
from .controllers.auth import Auth
-from .tools import load_controllers, json_error_page
+from .tools import load_controllers, json_error_page, SessionExpireAtBrowserCloseTool
# cherrypy likes to sys.exit on error. don't let it take us down too!
self.log.info('server_addr: %s server_port: %s', server_addr,
server_port)
+ # Initialize custom handlers.
+ cherrypy.tools.authenticate = cherrypy.Tool('before_handler', Auth.check_auth)
+ cherrypy.tools.session_expire_at_browser_close = SessionExpireAtBrowserCloseTool()
+
+ # Apply the 'global' CherryPy configuration.
+ config = {
+ 'engine.autoreload.on': False
+ }
if not in_unittest:
- cherrypy.config.update({
+ config.update({
'server.socket_host': server_addr,
'server.socket_port': int(server_port),
- 'engine.autoreload.on': False,
'error_page.default': json_error_page
})
- cherrypy.tools.authenticate = cherrypy.Tool('before_handler',
- Auth.check_auth)
+ cherrypy.config.update(config)
current_dir = os.path.dirname(os.path.abspath(__file__))
fe_dir = os.path.join(current_dir, 'frontend/dist')
from .helper import ControllerTestCase
from ..controllers.auth import Auth
+from ..tools import Session
class Ping(object):
self._post("/api/auth", {'username': 'admin', 'password': 'admin'})
self.assertStatus('201 Created')
self.assertJsonBody({"username": "admin"})
- self.assertEqual(sess_mock.get(Auth.SESSION_KEY), 'admin')
+ self.assertEqual(sess_mock.get(Session.USERNAME), 'admin')
+
+ def test_login_stay_signed_in(self):
+ sess_mock = RamSession()
+ with patch('cherrypy.session', sess_mock, create=True):
+ self._post("/api/auth", {
+ 'username': 'admin',
+ 'password': 'admin',
+ 'stay_signed_in': True})
+ self.assertStatus('201 Created')
+ self.assertEqual(sess_mock.get(
+ Session.EXPIRE_AT_BROWSER_CLOSE), False)
+ for _, content in self.cookies:
+ parts = map(str.strip, content.split(';'))
+ parts = {k: v for k, v in (part.split('=') for part in parts)}
+ if Session.NAME in parts:
+ self.assertIn('expires', parts)
+ self.assertIn('Max-Age', parts)
+
+ def test_login_not_stay_signed_in(self):
+ sess_mock = RamSession()
+ with patch('cherrypy.session', sess_mock, create=True):
+ self._post("/api/auth", {
+ 'username': 'admin',
+ 'password': 'admin',
+ 'stay_signed_in': False})
+ self.assertStatus('201 Created')
+ self.assertEqual(sess_mock.get(
+ Session.EXPIRE_AT_BROWSER_CLOSE), True)
+ for _, content in self.cookies:
+ parts = map(str.strip, content.split(';'))
+ parts = {k: v for k, v in (part.split('=') for part in parts)}
+ if Session.NAME in parts:
+ self.assertNotIn('expires', parts)
+ self.assertNotIn('Max-Age', parts)
def test_login_invalid(self):
sess_mock = RamSession()
self._post("/api/auth", {'username': 'admin', 'password': 'inval'})
self.assertStatus('403 Forbidden')
self.assertJsonBody({"detail": "Invalid credentials"})
- self.assertEqual(sess_mock.get(Auth.SESSION_KEY), None)
+ self.assertEqual(sess_mock.get(Session.USERNAME), None)
def test_logout(self):
sess_mock = RamSession()
with patch('cherrypy.session', sess_mock, create=True):
self._post("/api/auth", {'username': 'admin', 'password': 'admin'})
- self.assertEqual(sess_mock.get(Auth.SESSION_KEY), 'admin')
+ self.assertEqual(sess_mock.get(Session.USERNAME), 'admin')
self._delete("/api/auth")
self.assertStatus('204 No Content')
self.assertBody('')
- self.assertEqual(sess_mock.get(Auth.SESSION_KEY), None)
+ self.assertEqual(sess_mock.get(Session.USERNAME), None)
def test_session_expire(self):
sess_mock = RamSession()
with patch('cherrypy.session', sess_mock, create=True):
self._post("/api/auth", {'username': 'admin', 'password': 'admin'})
self.assertStatus('201 Created')
- self.assertEqual(sess_mock.get(Auth.SESSION_KEY), 'admin')
+ self.assertEqual(sess_mock.get(Session.USERNAME), 'admin')
self._post("/api/test/ping")
self.assertStatus('200 OK')
- self.assertEqual(sess_mock.get(Auth.SESSION_KEY), 'admin')
+ self.assertEqual(sess_mock.get(Session.USERNAME), 'admin')
time.sleep(3)
self._post("/api/test/ping")
self.assertStatus('401 Unauthorized')
- self.assertEqual(sess_mock.get(Auth.SESSION_KEY), None)
+ self.assertEqual(sess_mock.get(Session.USERNAME), None)
def test_unauthorized(self):
sess_mock = RamSession()
with patch('cherrypy.session', sess_mock, create=True):
self._post("/api/test/ping")
self.assertStatus('401 Unauthorized')
- self.assertEqual(sess_mock.get(Auth.SESSION_KEY), None)
+ self.assertEqual(sess_mock.get(Session.USERNAME), None)
def decorate(cls):
cls._cp_controller_ = True
cls._cp_path_ = path
+ config = {
+ 'tools.sessions.on': True,
+ 'tools.sessions.name': Session.NAME,
+ 'tools.session_expire_at_browser_close.on': True
+ }
if not hasattr(cls, '_cp_config'):
cls._cp_config = dict(cls._cp_config_default)
- cls._cp_config.update({
- 'tools.sessions.on': True,
- 'tools.authenticate.on': False
- })
+ config['tools.authenticate.on'] = False
else:
cls._cp_config.update(cls._cp_config_default)
- cls._cp_config['tools.sessions.on'] = True
if 'tools.authenticate.on' not in cls._cp_config:
- cls._cp_config['tools.authenticate.on'] = False
+ config['tools.authenticate.on'] = False
+ cls._cp_config.update(config)
return cls
return decorate
func.detail_route_methods = [m.upper() for m in methods]
return func
return decorator
+
+
+class Session(object):
+ """
+ This class contains all relevant settings related to cherrypy.session.
+ """
+ NAME = 'session_id'
+
+ # The keys used to store the information in the cherrypy.session.
+ USERNAME = '_username'
+ TS = '_ts'
+ EXPIRE_AT_BROWSER_CLOSE = '_expire_at_browser_close'
+
+ # The default values.
+ DEFAULT_EXPIRE = 1200.0
+
+
+class SessionExpireAtBrowserCloseTool(cherrypy.Tool):
+ """
+ A CherryPi Tool which takes care that the cookie does not expire
+ at browser close if the 'Keep me logged in' checkbox was selected
+ on the login page.
+ """
+ def __init__(self):
+ cherrypy.Tool.__init__(self, 'before_finalize', self._callback)
+
+ def _callback(self):
+ # Shall the cookie expire at browser close?
+ expire_at_browser_close = cherrypy.session.get(
+ Session.EXPIRE_AT_BROWSER_CLOSE, True)
+ if expire_at_browser_close:
+ # Get the cookie and its name.
+ cookie = cherrypy.response.cookie
+ name = cherrypy.request.config.get(
+ 'tools.sessions.name', Session.NAME)
+ # Make the cookie a session cookie by purging the
+ # fields 'expires' and 'max-age'.
+ if name in cookie:
+ del cookie[name]['expires']
+ del cookie[name]['max-age']