1 | #!/usr/bin/env python3
|
---|
2 | # -*- coding: utf-8 -*-
|
---|
3 | #
|
---|
4 | # Copyright (C) 2008-2023 Edgewall Software
|
---|
5 | # Copyright (C) 2008 Eli Carter
|
---|
6 | # All rights reserved.
|
---|
7 | #
|
---|
8 | # This software is licensed as described in the file COPYING, which
|
---|
9 | # you should have received as part of this distribution. The terms
|
---|
10 | # are also available at https://trac.edgewall.org/wiki/TracLicense.
|
---|
11 | #
|
---|
12 | # This software consists of voluntary contributions made by many
|
---|
13 | # individuals. For the exact contribution history, see the revision
|
---|
14 | # history and logs, available at https://trac.edgewall.org/.
|
---|
15 |
|
---|
16 | import argparse
|
---|
17 | import getpass
|
---|
18 | import sys
|
---|
19 |
|
---|
20 | try:
|
---|
21 | import passlib
|
---|
22 | except ImportError:
|
---|
23 | passlib = None
|
---|
24 | try:
|
---|
25 | import crypt
|
---|
26 | except ImportError:
|
---|
27 | crypt = None
|
---|
28 | else:
|
---|
29 | crypt = None
|
---|
30 |
|
---|
31 | from trac.util.compat import wait_for_file_mtime_change
|
---|
32 | from trac.util.text import printerr
|
---|
33 |
|
---|
34 |
|
---|
35 | if passlib:
|
---|
36 | from passlib.context import CryptContext
|
---|
37 | _crypt_schemes = {
|
---|
38 | 'sha256': 'sha256_crypt',
|
---|
39 | 'sha512': 'sha512_crypt',
|
---|
40 | 'md5': 'apr_md5_crypt',
|
---|
41 | 'des': 'des_crypt',
|
---|
42 | }
|
---|
43 | from passlib.hash import bcrypt
|
---|
44 | try:
|
---|
45 | bcrypt.get_backend()
|
---|
46 | except passlib.exc.MissingBackendError:
|
---|
47 | pass
|
---|
48 | else:
|
---|
49 | _crypt_schemes['bcrypt'] = 'bcrypt'
|
---|
50 | _crypt_context = CryptContext(schemes=sorted(_crypt_schemes.values()))
|
---|
51 | _hash_methods = sorted(_crypt_schemes)
|
---|
52 | def hash_password(word, method):
|
---|
53 | scheme = _crypt_schemes[method]
|
---|
54 | if hasattr(_crypt_context, 'hash'): # passlib 1.7+
|
---|
55 | hash_ = _crypt_context.hash
|
---|
56 | else:
|
---|
57 | hash_ = _crypt_context.encrypt
|
---|
58 | return hash_(word, scheme=scheme)
|
---|
59 | elif crypt:
|
---|
60 | _crypt_methods = {
|
---|
61 | 'sha256': crypt.METHOD_SHA256,
|
---|
62 | 'sha512': crypt.METHOD_SHA512,
|
---|
63 | 'md5': None, # use md5crypt
|
---|
64 | 'des': crypt.METHOD_CRYPT,
|
---|
65 | }
|
---|
66 | if hasattr(crypt, 'METHOD_BLOWFISH'):
|
---|
67 | _crypt_methods['bcrypt'] = crypt.METHOD_BLOWFISH
|
---|
68 | _hash_methods = sorted(_crypt_methods)
|
---|
69 | from trac.util import salt, md5crypt
|
---|
70 | def hash_password(word, method):
|
---|
71 | if method == 'md5':
|
---|
72 | return md5crypt(word, salt(), '$apr1$')
|
---|
73 | else:
|
---|
74 | return crypt.crypt(word, crypt.mksalt(_crypt_methods[method]))
|
---|
75 | else:
|
---|
76 | printerr("The crypt module is not found. Install the passlib package "
|
---|
77 | "from PyPI.", newline=True)
|
---|
78 | sys.exit(1)
|
---|
79 |
|
---|
80 |
|
---|
81 | def ask_pass():
|
---|
82 | pass1 = getpass.getpass('New password: ')
|
---|
83 | pass2 = getpass.getpass('Re-type new password: ')
|
---|
84 | if pass1 != pass2:
|
---|
85 | printerr("htpasswd: password verification error")
|
---|
86 | sys.exit(1)
|
---|
87 | return pass1
|
---|
88 |
|
---|
89 |
|
---|
90 | class HtpasswdFile(object):
|
---|
91 | """A class for manipulating htpasswd files."""
|
---|
92 |
|
---|
93 | def __init__(self, filename, create=False):
|
---|
94 | self.entries = []
|
---|
95 | self.filename = filename
|
---|
96 | if not create:
|
---|
97 | self.load()
|
---|
98 |
|
---|
99 | def load(self):
|
---|
100 | """Read the htpasswd file into memory."""
|
---|
101 | self.entries = []
|
---|
102 | with open(self.filename, 'r', encoding='utf-8') as f:
|
---|
103 | for line in f:
|
---|
104 | username, pwhash = line.split(':')
|
---|
105 | entry = [username, pwhash.rstrip()]
|
---|
106 | self.entries.append(entry)
|
---|
107 |
|
---|
108 | def save(self):
|
---|
109 | """Write the htpasswd file to disk"""
|
---|
110 | wait_for_file_mtime_change(self.filename)
|
---|
111 | with open(self.filename, 'w', encoding='utf-8') as f:
|
---|
112 | f.writelines("%s:%s\n" % (entry[0], entry[1])
|
---|
113 | for entry in self.entries)
|
---|
114 |
|
---|
115 | def update(self, username, password, method):
|
---|
116 | """Replace the entry for the given user, or add it if new."""
|
---|
117 | pwhash = hash_password(password, method)
|
---|
118 | matching_entries = [entry for entry in self.entries
|
---|
119 | if entry[0] == username]
|
---|
120 | if matching_entries:
|
---|
121 | matching_entries[0][1] = pwhash
|
---|
122 | else:
|
---|
123 | self.entries.append([username, pwhash])
|
---|
124 |
|
---|
125 | def delete(self, username):
|
---|
126 | """Remove the entry for the given user."""
|
---|
127 | self.entries = [entry for entry in self.entries
|
---|
128 | if entry[0] != username]
|
---|
129 |
|
---|
130 |
|
---|
131 | def main():
|
---|
132 | """
|
---|
133 | %(prog)s [-c] passwordfile username
|
---|
134 | %(prog)s -b[c] passwordfile username password
|
---|
135 | %(prog)s -D passwordfile username\
|
---|
136 | """
|
---|
137 |
|
---|
138 | parser = argparse.ArgumentParser(usage=main.__doc__)
|
---|
139 | parser.add_argument('-b', action='store_true', dest='batch',
|
---|
140 | help="batch mode; password is passed on the command "
|
---|
141 | "line IN THE CLEAR")
|
---|
142 | parser_group = parser.add_mutually_exclusive_group()
|
---|
143 | parser_group.add_argument('-c', action='store_true', dest='create',
|
---|
144 | help="create a new htpasswd file, overwriting "
|
---|
145 | "any existing file")
|
---|
146 | parser_group.add_argument('-D', action='store_true', dest='delete_user',
|
---|
147 | help="remove the given user from the password "
|
---|
148 | "file")
|
---|
149 | parser.add_argument('-t', dest='method', choices=_hash_methods,
|
---|
150 | default='md5', help='hash method for passwords '
|
---|
151 | '(default: %(default)s)')
|
---|
152 | parser.add_argument('passwordfile', help=argparse.SUPPRESS)
|
---|
153 | parser.add_argument('username', help=argparse.SUPPRESS)
|
---|
154 | parser.add_argument('password', nargs='?', help=argparse.SUPPRESS)
|
---|
155 |
|
---|
156 | args = parser.parse_args()
|
---|
157 | password = args.password
|
---|
158 | if args.delete_user:
|
---|
159 | if password is not None:
|
---|
160 | parser.error("too many arguments")
|
---|
161 | else:
|
---|
162 | if args.batch and password is None:
|
---|
163 | parser.error("too few arguments")
|
---|
164 | elif not args.batch and password is not None:
|
---|
165 | parser.error("too many arguments")
|
---|
166 |
|
---|
167 | try:
|
---|
168 | passwdfile = HtpasswdFile(args.passwordfile, create=args.create)
|
---|
169 | except EnvironmentError:
|
---|
170 | printerr("File not found.")
|
---|
171 | sys.exit(1)
|
---|
172 | else:
|
---|
173 | if args.delete_user:
|
---|
174 | passwdfile.delete(args.username)
|
---|
175 | else:
|
---|
176 | if password is None:
|
---|
177 | password = ask_pass()
|
---|
178 | passwdfile.update(args.username, password, args.method)
|
---|
179 | passwdfile.save()
|
---|
180 |
|
---|
181 |
|
---|
182 | if __name__ == '__main__':
|
---|
183 | main()
|
---|