mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
223 lines
7.4 KiB
Python
223 lines
7.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
""" License
|
|
|
|
Copyright (C) 2018 YunoHost
|
|
|
|
This program is free software; you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published
|
|
by the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program 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 Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program; if not, see http://www.gnu.org/licenses
|
|
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import string
|
|
import subprocess
|
|
|
|
SMALL_PWD_LIST = [
|
|
"yunohost",
|
|
"olinuxino",
|
|
"olinux",
|
|
"raspberry",
|
|
"admin",
|
|
"root",
|
|
"test",
|
|
"rpi",
|
|
]
|
|
|
|
MOST_USED_PASSWORDS = "/usr/share/yunohost/100000-most-used-passwords.txt"
|
|
|
|
# Length, digits, lowers, uppers, others
|
|
STRENGTH_LEVELS = [
|
|
(8, 0, 0, 0, 0),
|
|
(8, 1, 1, 1, 0),
|
|
(8, 1, 1, 1, 1),
|
|
(12, 1, 1, 1, 1),
|
|
]
|
|
|
|
|
|
def assert_password_is_compatible(password):
|
|
"""
|
|
UNIX seems to not like password longer than 127 chars ...
|
|
e.g. SSH login gets broken (or even 'su admin' when entering the password)
|
|
"""
|
|
|
|
if len(password) >= 127:
|
|
|
|
# Note that those imports are made here and can't be put
|
|
# on top (at least not the moulinette ones)
|
|
# because the moulinette needs to be correctly initialized
|
|
# as well as modules available in python's path.
|
|
from yunohost.utils.error import YunohostValidationError
|
|
|
|
raise YunohostValidationError("admin_password_too_long")
|
|
|
|
|
|
def assert_password_is_strong_enough(profile, password):
|
|
|
|
PasswordValidator(profile).validate(password)
|
|
|
|
|
|
class PasswordValidator:
|
|
def __init__(self, profile):
|
|
"""
|
|
Initialize a password validator.
|
|
|
|
The profile shall be either "user" or "admin"
|
|
and will correspond to a validation strength
|
|
defined via the setting "security.password.<profile>.strength"
|
|
"""
|
|
|
|
self.profile = profile
|
|
try:
|
|
# We do this "manually" instead of using settings_get()
|
|
# from settings.py because this file is also meant to be
|
|
# use as a script by ssowat.
|
|
# (or at least that's my understanding -- Alex)
|
|
settings = json.load(open("/etc/yunohost/settings.json", "r"))
|
|
setting_key = "security.password." + profile + ".strength"
|
|
self.validation_strength = int(settings[setting_key]["value"])
|
|
except Exception:
|
|
# Fallback to default value if we can't fetch settings for some reason
|
|
self.validation_strength = 1
|
|
|
|
def validate(self, password):
|
|
"""
|
|
Check the validation_summary and trigger an exception
|
|
if the password does not pass tests.
|
|
|
|
This method is meant to be used from inside YunoHost's code
|
|
(compared to validation_summary which is meant to be called
|
|
by ssowat)
|
|
"""
|
|
if self.validation_strength == -1:
|
|
return
|
|
|
|
# Note that those imports are made here and can't be put
|
|
# on top (at least not the moulinette ones)
|
|
# because the moulinette needs to be correctly initialized
|
|
# as well as modules available in python's path.
|
|
from yunohost.utils.error import YunohostValidationError
|
|
|
|
status, msg = self.validation_summary(password)
|
|
if status == "error":
|
|
raise YunohostValidationError(msg)
|
|
|
|
def validation_summary(self, password):
|
|
"""
|
|
Check if a password is listed in the list of most used password
|
|
and if the overall strength is good enough compared to the
|
|
validation_strength defined in the constructor.
|
|
|
|
Produces a summary-tuple comprised of a level (succes or error)
|
|
and a message key describing the issues found.
|
|
"""
|
|
if self.validation_strength < 0:
|
|
return ("success", "")
|
|
|
|
listed = password in SMALL_PWD_LIST or self.is_in_most_used_list(password)
|
|
strength_level = self.strength_level(password)
|
|
if listed:
|
|
# i18n: password_listed
|
|
return ("error", "password_listed")
|
|
if strength_level < self.validation_strength:
|
|
# i18n: password_too_simple_1
|
|
# i18n: password_too_simple_2
|
|
# i18n: password_too_simple_3
|
|
# i18n: password_too_simple_4
|
|
return ("error", "password_too_simple_%s" % self.validation_strength)
|
|
|
|
return ("success", "")
|
|
|
|
def strength(self, password):
|
|
"""
|
|
Returns the strength of a password, defined as a tuple
|
|
containing the length of the password, the number of digits,
|
|
lowercase letters, uppercase letters, and other characters.
|
|
|
|
For instance, "PikachuDu67" is (11, 2, 7, 2, 0)
|
|
"""
|
|
|
|
length = len(password)
|
|
digits = 0
|
|
uppers = 0
|
|
lowers = 0
|
|
others = 0
|
|
|
|
for character in password:
|
|
if character in string.digits:
|
|
digits = digits + 1
|
|
elif character in string.ascii_uppercase:
|
|
uppers = uppers + 1
|
|
elif character in string.ascii_lowercase:
|
|
lowers = lowers + 1
|
|
else:
|
|
others = others + 1
|
|
|
|
return (length, digits, lowers, uppers, others)
|
|
|
|
def strength_level(self, password):
|
|
"""
|
|
Computes the strength of a password and compares
|
|
it to the STRENGTH_LEVELS.
|
|
|
|
Returns an int corresponding to the highest STRENGTH_LEVEL
|
|
satisfied by the password.
|
|
"""
|
|
|
|
strength = self.strength(password)
|
|
|
|
strength_level = 0
|
|
# Iterate over each level and its criterias
|
|
for level, level_criterias in enumerate(STRENGTH_LEVELS):
|
|
# Iterate simulatenously over the level criterias (e.g. [8, 1, 1, 1, 0])
|
|
# and the strength of the password (e.g. [11, 2, 7, 2, 0])
|
|
# and compare the values 1-by-1.
|
|
# If one False is found, the password does not satisfy the level
|
|
if False in [s >= c for s, c in zip(strength, level_criterias)]:
|
|
break
|
|
# Otherwise, the strength of the password is at least of the current level.
|
|
strength_level = level + 1
|
|
|
|
return strength_level
|
|
|
|
def is_in_most_used_list(self, password):
|
|
|
|
# Decompress file if compressed
|
|
if os.path.exists("%s.gz" % MOST_USED_PASSWORDS):
|
|
os.system("gzip -fd %s.gz" % MOST_USED_PASSWORDS)
|
|
|
|
# Grep the password in the file
|
|
# We use '-f -' to feed the pattern (= the password) through
|
|
# stdin to avoid it being shown in ps -ef --forest...
|
|
command = "grep -q -F -f - %s" % MOST_USED_PASSWORDS
|
|
p = subprocess.Popen(command.split(), stdin=subprocess.PIPE)
|
|
p.communicate(input=password.encode("utf-8"))
|
|
return not bool(p.returncode)
|
|
|
|
|
|
# This file is also meant to be used as an executable by
|
|
# SSOwat to validate password from the portal when an user
|
|
# change its password.
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
import getpass
|
|
|
|
pwd = getpass.getpass("")
|
|
# print("usage: password.py PASSWORD")
|
|
else:
|
|
pwd = sys.argv[1]
|
|
status, msg = PasswordValidator("user").validation_summary(pwd)
|
|
print(msg)
|
|
sys.exit(0)
|