Coverage for nlp_webserver/manage_users.py: 18%
96 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-08-27 10:34 -0500
« prev ^ index » next coverage.py v7.8.0, created at 2025-08-27 10:34 -0500
1#!/usr/bin/env python
3r"""
4crate_anon/nlp_webserver/manage_users.py
6===============================================================================
8 Copyright (C) 2015, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CRATE.
13 CRATE is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CRATE is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CRATE. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28Manages the user authentication file for CRATE's implementation of an NLPRP
29server.
31"""
33import argparse
34import logging
35from shutil import copyfile
36from typing import Dict
38from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger
39from rich_argparse import RichHelpFormatter
41from crate_anon.nlp_webserver.constants import NlpServerConfigKeys
42from crate_anon.nlp_webserver.security import hash_password
43from crate_anon.nlp_webserver.settings import SETTINGS, SETTINGS_PATH
45log = logging.getLogger(__name__)
47USERS_FILENAME = SETTINGS[NlpServerConfigKeys.USERS_FILE]
50def get_users() -> Dict[str, str]:
51 """
52 Reads the user file and returns a dictionary mapping usernames to hashed
53 passwords.
54 """
55 with open(USERS_FILENAME, "r") as user_file:
56 user_lines = user_file.readlines()
57 user_elements = [x.split(",") for x in user_lines]
58 users = {x[0]: x[1].strip() for x in user_elements}
59 return users
62def add_user(username: str, password: str) -> None:
63 """
64 Adds a username/password combination to the users file, hashing the
65 password en route.
66 """
67 users = get_users()
68 if username in users:
69 proceed = input(
70 f"User {username} already exists. "
71 f"Overwrite (change password)? [yes/no] "
72 )
73 if proceed.lower() == "yes":
74 change_password(username, password)
75 return
76 else:
77 return
78 with open(USERS_FILENAME, "a") as user_file:
79 user_file.write(f"{username},{hash_password(password)}\n")
80 log.info(f"User {username} added.")
83def rm_user(username: str) -> None:
84 """
85 Removes a user from the user file.
86 """
87 user_found = False
88 # Create a backup in case something goes wrong during writing
89 backup_filename = USERS_FILENAME + "~"
90 copyfile(USERS_FILENAME, backup_filename)
91 users = get_users()
92 try:
93 with open(USERS_FILENAME, "w") as user_file:
94 for user in users:
95 if user != username:
96 user_file.write(f"{user},{users[user]}\n")
97 else:
98 user_found = True
99 except IOError:
100 log.error(
101 f"An error occured in opening the file {USERS_FILENAME}. If the "
102 f"integrity of this file is compromised, the backup is "
103 f"{backup_filename}."
104 )
105 raise
106 if user_found:
107 log.info(f"User {username} removed.")
108 else:
109 log.info(f"User {username} not found.")
112def change_password(username: str, password: str) -> None:
113 """
114 Changes a user's password by rewriting the user file.
115 """
116 user_found = False
117 # Create a backup in case something goes wrong during writing
118 backup_filename = USERS_FILENAME + "~"
119 copyfile(USERS_FILENAME, backup_filename)
120 users = get_users()
121 try:
122 with open(USERS_FILENAME, "w") as user_file:
123 for user in users:
124 if user != username:
125 user_file.write(f"{user},{users[user]}\n")
126 else:
127 user_found = True
128 user_file.write(f"{username},{hash_password(password)}\n")
129 except IOError:
130 log.error(
131 f"An error occured in opening the file {USERS_FILENAME}. If the "
132 f"integrity of this file is compromised, the backup is "
133 f"{backup_filename}."
134 )
135 raise
136 if user_found:
137 log.info(f"Password changed for user {username}.")
138 else:
139 log.info(f"User {username} not found.")
142def main() -> None:
143 """
144 Command-line entry point.
145 """
146 description = "Manage users for the CRATE nlp_web server."
148 # noinspection PyTypeChecker
149 parser = argparse.ArgumentParser(
150 description=description,
151 formatter_class=RichHelpFormatter,
152 )
153 arg_group = parser.add_mutually_exclusive_group()
154 arg_group.add_argument(
155 "--adduser",
156 nargs=2,
157 metavar=("USERNAME", "PASSWORD"),
158 help="Add a user and associated password.",
159 )
160 arg_group.add_argument(
161 "--rmuser",
162 nargs=1,
163 metavar="USERNAME",
164 help="Remove a user by specifying their username.",
165 )
166 arg_group.add_argument(
167 "--changepw",
168 nargs=2,
169 metavar=("USERNAME", "PASSWORD"),
170 help="Change a user's password.",
171 )
172 args = parser.parse_args()
174 main_only_quicksetup_rootlogger()
176 if not args.adduser and not args.rmuser and not args.changepw:
177 log.error(
178 "One option required: '--adduser', '--rmuser' or '--changepw'."
179 )
180 return
182 log.debug(f"Settings file: {SETTINGS_PATH}")
183 log.debug(f"Users file: {USERS_FILENAME}")
184 if args.rmuser:
185 username = args.rmuser[0]
186 proceed = input(f"Confirm remove user: {username} ? [yes/no] ")
187 if proceed.lower() == "yes":
188 rm_user(username)
189 else:
190 log.info("User remove aborted.")
191 elif args.adduser:
192 username = args.adduser[0]
193 password = args.adduser[1]
194 add_user(username, password)
195 elif args.changepw:
196 username = args.changepw[0]
197 new_password = args.changepw[1]
198 proceed = input(
199 f"Confirm change password for user: {username} ? [yes/no] "
200 )
201 if proceed.lower() == "yes":
202 change_password(username, new_password)
203 else:
204 log.info("Password change aborted.")
207if __name__ == "__main__":
208 main()