diff --git a/lab2/.gitignore b/lab2/.gitignore new file mode 100644 index 0000000..7e99e36 --- /dev/null +++ b/lab2/.gitignore @@ -0,0 +1 @@ +*.pyc \ No newline at end of file diff --git a/lab2/.python-version b/lab2/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/lab2/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/lab2/README.md b/lab2/README.md new file mode 100644 index 0000000..b00c268 --- /dev/null +++ b/lab2/README.md @@ -0,0 +1,99 @@ +# Lab 2 — Authentication, Authorization & Brute Force Research + +Система доступа к конфиденциальным данным с управлением пользователями и исследованием стойкости паролей. + +## Структура каталогов + +``` +$PRACTICE2_DIR/ # по умолчанию /usr/local/practice2 +├── etc/passwd # логин:sha256:id:права:ФИО +├── confdata/ # конфиденциальные файлы +├── bin/ # утилиты (usermgr, confaccess, bruteforce) +└── log/ # usermgr.log, access.log +``` + +Базовый каталог задаётся через переменную окружения `PRACTICE2_DIR`. +Если переменная не задана — используется `/usr/local/practice2`. + +## Установка + +```bash +chmod +x setup.sh + +# для пути по умолчанию (/usr/local/practice2) нужен root: +sudo ./setup.sh + +# для тестирования без root: +PRACTICE2_DIR=/tmp/practice2 ./setup.sh +``` + +Скрипт создаёт структуру каталогов, копирует утилиты в `bin/` и выставляет права доступа. + +### Добавить bin во временный PATH + +```bash +export PATH="/usr/local/practice2/bin:$PATH" + +# или для тестовой директории: +export PATH="/tmp/practice2/bin:$PATH" +``` + +После этого утилиты доступны без полного пути: + +```bash +usermgr add alice +confaccess +bruteforce alice +``` + +## Использование + +### usermgr — управление пользователями + +```bash +usermgr add alice # добавить пользователя (интерактивный ввод) +usermgr list # список пользователей +usermgr edit alice --permissions rw +usermgr edit alice --full-name "Иванов Иван" +usermgr passwd alice # сменить пароль +usermgr delete alice # удалить пользователя +``` + +Права: `r` — чтение, `w` — запись, `d` — удаление. + +Требования к паролю: первый символ — буква (A–Z, a–z), далее — буквы, цифры и `!@#$%^&*()`. + +### confaccess — доступ к данным + +```bash +confaccess +# или с явным указанием базовой директории: +PRACTICE2_DIR=/tmp/practice2 confaccess +``` + +После аутентификации доступны команды: + +``` +create создать новый пустой файл в confdata [requires: w] +read вывести содержимое файла [requires: r] +append дописать строку в файл [requires: w] +copy скопировать файл в confdata [requires: r, w] +remove удалить файл из confdata [requires: d] +help / exit +``` + +Пути к файлам указываются относительно `confdata/` (или абсолютные). +Для `copy`: src — любой путь, dst — внутри confdata, перезапись запрещена. +Выход — `exit` или Ctrl+C. + +### bruteforce — взлом пароля + +```bash +bruteforce alice +bruteforce alice --max-length 4 +``` + +Алгоритм хэширования: **SHA-256**. +Перебор выполняется напрямую по хэшу из passwd-файла. +Фиксируется время перебора и количество итераций до нахождения пароля. +Перебор останавливается автоматически при достижении лимита 8 часов. diff --git a/lab2/access.py b/lab2/access.py new file mode 100644 index 0000000..2f480d5 --- /dev/null +++ b/lab2/access.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +import getpass +import hashlib +import shutil +import signal +import sys +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from config import CONFDATA_DIR, LOG_DIR, PASSWD_FILE + +HELP_TEXT = """\ +Commands: + create create new empty file [requires: w] + read print file contents [requires: r] + append append text line to file [requires: w] + copy copy file into confdata [requires: r, w] + remove delete file from confdata [requires: d] + help show this help + exit exit""" + + +def hash_password(password: str) -> str: + return hashlib.sha256(password.encode("ascii")).hexdigest() + + +def log_action(login: str, action: str) -> None: + LOG_DIR.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().isoformat(sep=" ", timespec="seconds") + with open(LOG_DIR / "access.log", "a") as f: + f.write(f"{timestamp} [{login}] {action}\n") + + +def read_users() -> dict[str, dict]: + users: dict[str, dict] = {} + if not PASSWD_FILE.exists(): + return users + with open(PASSWD_FILE) as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split(":", 4) + if len(parts) != 5: + continue + users[parts[0]] = { + "password_hash": parts[1], + "id": parts[2], + "permissions": parts[3], + "full_name": parts[4], + } + return users + + +def authenticate() -> tuple[str, dict]: + users = read_users() + while True: + try: + login = input("Login: ").strip() + password = getpass.getpass("Password: ") + except (EOFError, KeyboardInterrupt): + print("\nBye.") + sys.exit(0) + + user = users.get(login) + if user and user["password_hash"] == hash_password(password): + return login, user + log_action(login if login else "-", "LOGIN_FAILED") + print("Invalid credentials. Try again.") + + +def confdata_path(arg: str) -> Path: + p = Path(arg) + if not p.is_absolute(): + p = CONFDATA_DIR / p + return p.resolve() + + +def is_in_confdata(path: Path) -> bool: + try: + path.relative_to(CONFDATA_DIR.resolve()) + return True + except ValueError: + return False + + +def cmd_read(args: list[str], login: str, perms: str) -> None: + if "r" not in perms: + print("Permission denied (requires: r)") + return + if len(args) != 1: + print("Usage: read ") + return + path = confdata_path(args[0]) + if not is_in_confdata(path): + print("Access denied: file must be inside confdata") + return + if not path.exists(): + print(f"File not found: {path.name}") + return + print(path.read_text(), end="") + log_action(login, f"READ {path}") + + +def cmd_create(args: list[str], login: str, perms: str) -> None: + if "w" not in perms: + print("Permission denied (requires: w)") + return + if len(args) != 1: + print("Usage: create ") + return + path = confdata_path(args[0]) + if not is_in_confdata(path): + print("Access denied: file must be inside confdata") + return + if path.exists(): + print(f"File already exists: {path.name}") + return + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + log_action(login, f"CREATE {path}") + print("Done.") + + +def cmd_append(args: list[str], login: str, perms: str) -> None: + if "w" not in perms: + print("Permission denied (requires: w)") + return + if len(args) < 2: + print("Usage: append ") + return + path = confdata_path(args[0]) + if not is_in_confdata(path): + print("Access denied: file must be inside confdata") + return + if not path.exists(): + print(f"File not found: {path.name}. Use 'create' first.") + return + text = " ".join(args[1:]) + with open(path, "a") as f: + f.write(text + "\n") + log_action(login, f"APPEND {path}") + print("Done.") + + +def cmd_copy(args: list[str], login: str, perms: str) -> None: + if "r" not in perms or "w" not in perms: + print("Permission denied (requires: r, w)") + return + if len(args) != 2: + print("Usage: copy ") + return + + src = Path(args[0]).resolve() + dst = confdata_path(args[1]) + + if not is_in_confdata(dst): + print("Access denied: destination must be inside confdata") + return + if dst.is_dir(): + dst = dst / src.name + if dst.exists(): + print(f"Destination already exists: {dst.name}") + return + if not src.exists(): + print(f"Source not found: {args[0]}") + return + if src.is_dir(): + print("Copying directories is not supported") + return + + shutil.copy2(src, dst) + log_action(login, f"COPY {src} -> {dst}") + print("Done.") + + +def cmd_remove(args: list[str], login: str, perms: str) -> None: + if "d" not in perms: + print("Permission denied (requires: d)") + return + if len(args) != 1: + print("Usage: remove ") + return + path = confdata_path(args[0]) + if not is_in_confdata(path): + print("Access denied: file must be inside confdata") + return + if not path.exists(): + print(f"File not found: {path.name}") + return + path.unlink() + log_action(login, f"REMOVE {path}") + print("Done.") + + +def main() -> None: + signal.signal(signal.SIGINT, lambda _s, _f: (print("\nBye."), sys.exit(0))) + + login, user = authenticate() + perms = user["permissions"] + full_name = user["full_name"] + + log_action(login, "LOGIN") + print(f"\nПривет, {full_name}") + print(HELP_TEXT) + + while True: + try: + line = input(f"\n{login}> ").strip() + except (EOFError, KeyboardInterrupt): + log_action(login, "EXIT") + print("\nBye.") + break + + if not line: + continue + + parts = line.split() + command, args = parts[0], parts[1:] + + if command == "exit": + log_action(login, "EXIT") + print("Bye.") + break + elif command == "help": + print(HELP_TEXT) + elif command == "create": + cmd_create(args, login, perms) + elif command == "read": + cmd_read(args, login, perms) + elif command == "append": + cmd_append(args, login, perms) + elif command == "copy": + cmd_copy(args, login, perms) + elif command == "remove": + cmd_remove(args, login, perms) + else: + print(f"Unknown command: {command!r}. Type 'help' for available commands.") + + +if __name__ == "__main__": + main() diff --git a/lab2/bruteforce.py b/lab2/bruteforce.py new file mode 100644 index 0000000..3c1e218 --- /dev/null +++ b/lab2/bruteforce.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import itertools +import string +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from config import PASSWD_FILE + +CHARSET = string.ascii_letters + string.digits + "!@#$%^&*()" +FIRST_CHARS = string.ascii_letters + +MAX_HOURS = 8 + + +def hash_password(password: str) -> str: + return hashlib.sha256(password.encode("ascii")).hexdigest() + + +def get_target_hash(login: str) -> str | None: + if not PASSWD_FILE.exists(): + return None + with open(PASSWD_FILE) as f: + for line in f: + parts = line.strip().split(":", 4) + if len(parts) == 5 and parts[0] == login: + return parts[1] + return None + + +def max_combinations(length: int) -> int: + if length == 1: + return len(FIRST_CHARS) + return len(FIRST_CHARS) * (len(CHARSET) ** (length - 1)) + + +def brute_force_length(target_hash: str, length: int) -> tuple[str, int, float] | None: + count = 0 + start = time.perf_counter() + + if length == 1: + for first in FIRST_CHARS: + count += 1 + if hash_password(first) == target_hash: + return first, count, time.perf_counter() - start + return None + + for first in FIRST_CHARS: + for rest in itertools.product(CHARSET, repeat=length - 1): + if time.perf_counter() - start > MAX_HOURS * 3600: + return None + count += 1 + password = first + "".join(rest) + if hash_password(password) == target_hash: + return password, count, time.perf_counter() - start + + return None + + +def main() -> None: + parser = argparse.ArgumentParser(description="Brute force password cracker (SHA-256)") + parser.add_argument("login", help="Target username") + parser.add_argument( + "--max-length", + type=int, + default=6, + help="Maximum password length to try (default: 6)", + ) + args = parser.parse_args() + + target_hash = get_target_hash(args.login) + if not target_hash: + print(f"User '{args.login}' not found in passwd file.") + return + + print(f"Target: {args.login}") + print(f"Hash: {target_hash}") + print(f"Charset size: {len(CHARSET)} ({len(FIRST_CHARS)} valid for first char)") + print(f"Algorithm: SHA-256") + print() + + for length in range(1, args.max_length + 1): + total = max_combinations(length) + print(f"Length {length}: max {total:>15,} combinations") + + result = brute_force_length(target_hash, length) + + if result is not None: + password, count, elapsed = result + print(f" >>> FOUND: '{password}'") + print(f" Iterations: {count:,}") + print(f" Time: {elapsed:.4f}s") + print(f" Speed: {count / elapsed:,.0f} hashes/s") + return + else: + print(f" Not found at length {length} (timeout or exhausted)") + + print(f"\nPassword not found within length {args.max_length}.") + + +if __name__ == "__main__": + main() diff --git a/lab2/config.py b/lab2/config.py new file mode 100644 index 0000000..ab756ea --- /dev/null +++ b/lab2/config.py @@ -0,0 +1,11 @@ +import os +from pathlib import Path + +BASE_DIR = Path(os.environ.get("PRACTICE2_DIR", "/usr/local/practice2")) + +ETC_DIR = BASE_DIR / "etc" +CONFDATA_DIR = BASE_DIR / "confdata" +BIN_DIR = BASE_DIR / "bin" +LOG_DIR = BASE_DIR / "log" + +PASSWD_FILE = ETC_DIR / "passwd" diff --git a/lab2/lab2.md b/lab2/lab2.md new file mode 100644 index 0000000..7e8df90 --- /dev/null +++ b/lab2/lab2.md @@ -0,0 +1,277 @@ +# Практическая работа №2 + +по дисциплине «Защита информации» + +**Тема работы:** «Разработка и исследование системы аутентификации и авторизации» +**Преподаватель:** Силиненко А.В. +**Email:** [a_silinenko@mail.ru](mailto:a_silinenko@mail.ru) + +--- + +## 1. Цели работы + +* Разработать систему доступа пользователей к конфиденциальным данным; +* Исследовать стойкость паролей к атаке методом грубой силы. + +--- + +## 2. Задачи работы + +### 2.1. + +При необходимости установить на компьютер целевую ОС (Linux или MacOS), в которой производится разработка и использование системы. + +### 2.2. + +Разработать систему доступа пользователей к конфиденциальным данным, включающую: + +* Выделенный каталог для хранения всех файлов системы; +* Утилиту для работы с данными аутентификации и авторизации (паролями и правами доступа); +* Утилиту доступа к конфиденциальным данным, обеспечивающую аутентификацию и авторизацию пользователя. + +### 2.3. + +Разработать программу взлома паролей методом грубой силы и исследовать стойкость паролей в зависимости от длины пароля и алгоритма хэширования. + +--- + +## 3. Требования к работе + +### 3.1. ОС + +Работа выполняется в ОС **Linux** или **MacOS**. + +Необходим доступ с правами суперпользователя. +Если доступа нет — установить гипервизор **VirtualBox** ([https://www.virtualbox.org/](https://www.virtualbox.org/)) и развернуть гостевую ОС. + +Допускается установка ОС как второй системы без VirtualBox. + +--- + +### 3.2. Структура каталогов + +Необходимо создать дерево каталогов: + +``` +/usr/local/practice2/ +├── etc # хранение данных аутентификации и авторизации +├── confdata # хранение конфиденциальных файлов +├── bin # разработанные утилиты +└── log # файлы регистрации +``` + +**Права доступа:** чтение, запись и выполнение только для пользователя `root`. + +--- + +### 3.3. Требования к утилите управления пользователями + +#### Файл хранения данных + +Файл: + +``` +/usr/local/practice2/etc/passwd +``` + +Структура записи: + +``` +<логин>:<хэш_пароля>:<идентификатор>:<права>:<ФИО> +``` + +Одна строка — один пользователь. + +**Права на файл:** чтение и запись только для `root`. + +#### Права доступа + +* `r` — чтение +* `w` — запись +* `d` — удаление + +#### Требования к функционалу + +Утилита должна обеспечивать: + +* Добавление нового пользователя: + + * логин + * ФИО + * права доступа + * пароль + подтверждение +* Проверку корректности данных +* Хранение пароля в виде **хэш-значения** +* Использование алгоритма хэширования согласно индивидуальному заданию +* Редактирование существующего пользователя +* Изменение пароля +* Удаление пользователя +* Регистрацию всех действий с файлом `passwd` + +#### Требования к паролю + +* Кодировка: ASCII +* Разрешены: + + * A–Z + * a–z + * 0–9 + * `!@#$%^&*()` +* Первый символ не может быть цифрой или спецсимволом + +--- + +### 3.4. Требования к утилите доступа к конфиденциальным данным + +#### Авторизация + +При запуске: + +1. Запрос логина +2. Запрос пароля +3. Проверка корректности + +Если данные корректны: + +* Вывод: `Привет, <ФИО>` +* Краткая справка +* Приглашение к вводу команд + +Если данные некорректны: + +* Повторный запрос + +Остановка — по сигналу **SIGINT (Ctrl+C)**. + +--- + +### Поддерживаемые команды + +| Команда | Описание | Требуемые права | +| -------- | ------------------------ | --------------- | +| `read` | Вывод содержимого файла | r | +| `append` | Добавление данных в файл | w | +| `copy` | Копирование файла | r + w | +| `remove` | Удаление файла | d | +| `exit` | Выход | — | +| `help` | Справка | — | + +#### Ограничения copy + +* Разрешено копирование: + + * в каталог `confdata` + * внутри `confdata` +* Запрещено: + + * из `confdata` в другие каталоги + * перезапись существующих файлов внутри `confdata` + +#### Дополнительные требования + +* Утилита доступна для запуска всем пользователям ОС +* Все действия с конфиденциальными данными логируются + +--- + +### 3.5. Программа взлома паролей + +Должна: + +* Запускать утилиту доступа +* Перебирать пароли методом brute force +* Учитывать используемый алгоритм хэширования +* Фиксировать: + + * время перебора + * количество итераций до взлома + +--- + +### 3.6. Общие требования + +* Язык программирования — любой +* Код должен быть снабжен комментариями + +--- + +### 3.7. Требования к исследованию + +Провести исследование для длин паролей: + +``` +3, 4, 5, 6, 7, 8 символов +``` + +Необходимо: + +* Рассчитать максимальное количество итераций +* По экспериментам (3–4 символа) оценить время для 5–8 +* Проверить теорию экспериментально +* Прервать эксперимент, если длительность > 8 часов +* Проводить тестирование без активных задач на ПК + +--- + +## 4. Требования к отчету + +В отчете необходимо указать: + +* Актуальность темы + +* Цель и задачи + +* Требования к системе + +* Характеристики ПК (процессор, память) + +* ОС и среду разработки + +* Используемый язык + +* Алгоритм хэширования + +* Примеры сборки (если применимо) + +* Примеры работы утилит + +* Пример расчета количества итераций и времени взлома + +* Таблицу или график с результатами: + + * рассчитанное количество итераций и время + * полученные экспериментальные данные + +* Выводы + +--- + +## 5. Справочная информация + +### Поддержка алгоритмов хэширования + +**Linux:** + +* `md5sum` +* `sha1sum` +* `sha256sum` +* `sha512sum` +* `b2sum` +* библиотека `openssl` + +**C++:** + +* `std::hash` + +**Python:** + +* модуль `hashlib` + +**Java:** + +* `java.security.MessageDigest` +* Spring Security + +**Go:** + +* пакет `hash` diff --git a/lab2/pyproject.toml b/lab2/pyproject.toml new file mode 100644 index 0000000..fa3cc51 --- /dev/null +++ b/lab2/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "lab2" +version = "0.1.0" +description = "Authentication, authorization and brute force research system" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [] + +[project.scripts] +usermgr = "usermgr:main" +access = "access:main" +bruteforce = "bruteforce:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +include = ["*.py"] diff --git a/lab2/setup.sh b/lab2/setup.sh new file mode 100755 index 0000000..2d4a0f8 --- /dev/null +++ b/lab2/setup.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BASE_DIR="${PRACTICE2_DIR:-/usr/local/practice2}" + +echo "Setting up directory structure at: $BASE_DIR" + +mkdir -p "$BASE_DIR/etc" "$BASE_DIR/confdata" "$BASE_DIR/bin" "$BASE_DIR/log" + +touch "$BASE_DIR/etc/passwd" + +cp "$SCRIPT_DIR/config.py" "$BASE_DIR/bin/config.py" +cp "$SCRIPT_DIR/usermgr.py" "$BASE_DIR/bin/usermgr" +cp "$SCRIPT_DIR/access.py" "$BASE_DIR/bin/confaccess" +cp "$SCRIPT_DIR/bruteforce.py" "$BASE_DIR/bin/bruteforce" + +chmod +x "$BASE_DIR/bin/usermgr" "$BASE_DIR/bin/confaccess" "$BASE_DIR/bin/bruteforce" + +if [ "$(id -u)" -eq 0 ]; then + chmod 700 "$BASE_DIR" "$BASE_DIR/etc" "$BASE_DIR/confdata" "$BASE_DIR/bin" "$BASE_DIR/log" + chmod 600 "$BASE_DIR/etc/passwd" + echo "Permissions set (root-only)." +else + echo "Warning: not running as root; skipping permission hardening." +fi + +echo "" +echo "Done. Directory layout:" +ls -la "$BASE_DIR" +echo "" +echo "Next steps:" +echo " $BASE_DIR/bin/usermgr add " +echo " $BASE_DIR/bin/confaccess" +echo " $BASE_DIR/bin/bruteforce " +echo "" +echo "To add bin to PATH temporarily:" +echo " export PATH=\"$BASE_DIR/bin:\$PATH\"" diff --git a/lab2/usermgr.py b/lab2/usermgr.py new file mode 100644 index 0000000..a3c1926 --- /dev/null +++ b/lab2/usermgr.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +import argparse +import getpass +import hashlib +import sys +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from config import LOG_DIR, PASSWD_FILE + +ALLOWED_CHARS = set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()" +) +FIRST_CHAR_ALLOWED = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") +VALID_PERM_CHARS = set("rwd") + + +def hash_password(password: str) -> str: + return hashlib.sha256(password.encode("ascii")).hexdigest() + + +def validate_password(password: str) -> str | None: + if not password: + return "password cannot be empty" + if password[0] not in FIRST_CHAR_ALLOWED: + return "first character must be a letter (A-Z, a-z)" + invalid = [ch for ch in password if ch not in ALLOWED_CHARS] + if invalid: + return f"invalid characters: {''.join(set(invalid))!r}" + return None + + +def validate_permissions(perms: str) -> str | None: + if not perms: + return "permissions cannot be empty" + for ch in perms: + if ch not in VALID_PERM_CHARS: + return f"invalid permission {ch!r}; allowed: r, w, d" + if len(set(perms)) != len(perms): + return "duplicate permissions" + return None + + +def log_action(action: str) -> None: + LOG_DIR.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().isoformat(sep=" ", timespec="seconds") + with open(LOG_DIR / "usermgr.log", "a") as f: + f.write(f"{timestamp} {action}\n") + + +def read_users() -> list[dict]: + if not PASSWD_FILE.exists(): + return [] + users = [] + with open(PASSWD_FILE) as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split(":", 4) + if len(parts) != 5: + continue + users.append( + { + "login": parts[0], + "password_hash": parts[1], + "id": parts[2], + "permissions": parts[3], + "full_name": parts[4], + } + ) + return users + + +def write_users(users: list[dict]) -> None: + PASSWD_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(PASSWD_FILE, "w") as f: + for u in users: + f.write( + f"{u['login']}:{u['password_hash']}:{u['id']}:{u['permissions']}:{u['full_name']}\n" + ) + + +def find_user(users: list[dict], login: str) -> dict | None: + return next((u for u in users if u["login"] == login), None) + + +def next_uid(users: list[dict]) -> str: + if not users: + return "1" + return str(max(int(u["id"]) for u in users) + 1) + + +def prompt_password() -> str: + while True: + password = getpass.getpass("Password: ") + err = validate_password(password) + if err: + print(f"Invalid password: {err}") + continue + confirm = getpass.getpass("Confirm password: ") + if password != confirm: + print("Passwords do not match.") + continue + return password + + +def cmd_add(args: argparse.Namespace) -> None: + users = read_users() + login = args.login + + if find_user(users, login): + print(f"User '{login}' already exists.") + sys.exit(1) + + full_name = input("Full name: ").strip() + if not full_name: + print("Full name cannot be empty.") + sys.exit(1) + + perms = input("Permissions (any combination of r, w, d): ").strip() + err = validate_permissions(perms) + if err: + print(f"Invalid permissions: {err}") + sys.exit(1) + + password = prompt_password() + + uid = next_uid(users) + users.append( + { + "login": login, + "password_hash": hash_password(password), + "id": uid, + "permissions": perms, + "full_name": full_name, + } + ) + write_users(users) + log_action(f"ADD login={login} id={uid} permissions={perms} full_name='{full_name}'") + print(f"User '{login}' added (id={uid}).") + + +def cmd_edit(args: argparse.Namespace) -> None: + users = read_users() + user = find_user(users, args.login) + if not user: + print(f"User '{args.login}' not found.") + sys.exit(1) + + changed = [] + + if args.full_name is not None: + if not args.full_name: + print("Full name cannot be empty.") + sys.exit(1) + user["full_name"] = args.full_name + changed.append("full_name") + + if args.permissions is not None: + err = validate_permissions(args.permissions) + if err: + print(f"Invalid permissions: {err}") + sys.exit(1) + user["permissions"] = args.permissions + changed.append("permissions") + + if not changed: + print("Nothing to change. Use --full-name and/or --permissions.") + sys.exit(0) + + write_users(users) + log_action(f"EDIT login={args.login} changed={','.join(changed)}") + print(f"User '{args.login}' updated: {', '.join(changed)}.") + + +def cmd_passwd(args: argparse.Namespace) -> None: + users = read_users() + user = find_user(users, args.login) + if not user: + print(f"User '{args.login}' not found.") + sys.exit(1) + + password = prompt_password() + user["password_hash"] = hash_password(password) + write_users(users) + log_action(f"PASSWD login={args.login}") + print(f"Password for '{args.login}' updated.") + + +def cmd_delete(args: argparse.Namespace) -> None: + users = read_users() + if not find_user(users, args.login): + print(f"User '{args.login}' not found.") + sys.exit(1) + + users = [u for u in users if u["login"] != args.login] + write_users(users) + log_action(f"DELETE login={args.login}") + print(f"User '{args.login}' deleted.") + + +def cmd_list(_args: argparse.Namespace) -> None: + users = read_users() + if not users: + print("No users.") + return + print(f"{'Login':<16} {'ID':<4} {'Perms':<6} Full Name") + print("-" * 52) + for u in users: + print(f"{u['login']:<16} {u['id']:<4} {u['permissions']:<6} {u['full_name']}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="User management utility for practice2") + sub = parser.add_subparsers(dest="command", required=True) + + p_add = sub.add_parser("add", help="Add a new user") + p_add.add_argument("login", help="Username (login)") + p_add.set_defaults(func=cmd_add) + + p_edit = sub.add_parser("edit", help="Edit an existing user") + p_edit.add_argument("login", help="Username to edit") + p_edit.add_argument("--full-name", dest="full_name", help="New full name") + p_edit.add_argument("--permissions", help="New permissions (e.g. rwd)") + p_edit.set_defaults(func=cmd_edit) + + p_passwd = sub.add_parser("passwd", help="Change user password") + p_passwd.add_argument("login", help="Username") + p_passwd.set_defaults(func=cmd_passwd) + + p_del = sub.add_parser("delete", help="Delete a user") + p_del.add_argument("login", help="Username to delete") + p_del.set_defaults(func=cmd_delete) + + p_list = sub.add_parser("list", help="List all users") + p_list.set_defaults(func=cmd_list) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/lab2/uv.lock b/lab2/uv.lock new file mode 100644 index 0000000..dca6f44 --- /dev/null +++ b/lab2/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "lab2" +version = "0.1.0" +source = { editable = "." }