-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecurity.py
More file actions
230 lines (181 loc) · 7.97 KB
/
Copy pathsecurity.py
File metadata and controls
230 lines (181 loc) · 7.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# security.py
# Handles password hashing, verification, and brute force slowdown.
import bcrypt
import time
from datetime import datetime, timedelta
import database
# ─────────────────────────────────────────────
# PASSWORD HASHING
# ─────────────────────────────────────────────
def hash_password(plain_text_password):
"""
Takes a plain password string and returns a bcrypt hash.
'rounds=12' means bcrypt runs 2^12 = 4096 internal iterations.
Higher = slower to crack, but also slightly slower for your app.
12 is the industry standard sweet spot.
bcrypt.hashpw() requires bytes, so we encode the string first.
The salt is generated automatically and embedded in the result.
"""
password_bytes = plain_text_password.encode("utf-8")
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password_bytes, salt)
# Store as a string in the database (decode from bytes)
return hashed.decode("utf-8")
def verify_password(plain_text_password, stored_hash):
"""
Checks if a plain password matches the stored bcrypt hash.
Returns True if correct, False if wrong.
bcrypt.checkpw() does the comparison safely —
it re-hashes the input and compares internally.
Never compare hashes with == yourself.
"""
try:
password_bytes = plain_text_password.encode("utf-8")
hash_bytes = stored_hash.encode("utf-8")
return bcrypt.checkpw(password_bytes, hash_bytes)
except Exception:
return False
# ─────────────────────────────────────────────
# BRUTE FORCE PROTECTION
# ─────────────────────────────────────────────
# How many wrong attempts before we lock the account
MAX_ATTEMPTS = 3
# How long the lockout lasts (in minutes) — doubles each time
BASE_LOCKOUT_MINUTES = 1
def get_delay_seconds(failed_attempts):
"""
Returns how many seconds to SLEEP before responding to a login attempt.
This slows down automated attack scripts dramatically.
Attempt 1 wrong: 0s delay
Attempt 2 wrong: 2s delay
Attempt 3 wrong: 4s delay
Attempt 4 wrong: 8s delay
Attempt 5+: account locked
"""
if failed_attempts <= 0:
return 0
delay = min(2 ** (failed_attempts - 1), 30) # cap at 30 seconds
return delay
# def is_account_locked(user):
# """
# Checks if the user's account is currently locked.
# Returns (is_locked: bool, message: str, seconds_remaining: int)
# """
# if not user["is_locked"] and not user["lockout_until"]:
# return False, "", 0
# if user["lockout_until"]:
# lockout_time = datetime.fromisoformat(user["lockout_until"])
# now = datetime.now()
# if now < lockout_time:
# remaining = int((lockout_time - now).total_seconds())
# minutes = remaining // 60
# seconds = remaining % 60
# msg = f"Account locked. Try again in {minutes}m {seconds}s."
# return True, msg, remaining
# else:
# # Lockout has expired — automatically unlock
# database.reset_failed_attempts(user["id"])
# return False, "", 0
# return True, "Account is permanently locked. Contact support.", 0
def is_account_locked(user):
"""
Checks only for BRUTE FORCE lockout (failed_attempts based).
Alert-based locks are handled separately in app.py before this is called.
"""
# No lockout at all
if not user["lockout_until"]:
return False, "", 0
# There is a timed lockout — check if it has expired
lockout_time = datetime.fromisoformat(user["lockout_until"])
now = datetime.now()
if now < lockout_time:
remaining = int((lockout_time - now).total_seconds())
minutes = remaining // 60
seconds = remaining % 60
msg = f"Account locked. Try again in {minutes}m {seconds}s."
return True, msg, remaining
else:
# Lockout expired — auto-unlock
database.reset_failed_attempts(user["id"])
return False, "", 0
def handle_failed_login(user):
"""
Called every time a user enters the wrong password.
- Increments the failure counter
- Applies a time delay (slows down attackers)
- Locks the account after MAX_ATTEMPTS failures
Returns a message to show the user.
"""
database.increment_failed_attempts(user["id"])
new_attempts = user["failed_attempts"] + 1
# Apply delay to slow down brute force scripts
delay = get_delay_seconds(new_attempts)
if delay > 0:
time.sleep(delay)
if new_attempts >= MAX_ATTEMPTS:
# Calculate lockout duration — doubles each time they hit the limit
lockout_minutes = BASE_LOCKOUT_MINUTES * (2 ** (new_attempts // MAX_ATTEMPTS - 1))
lockout_minutes = min(lockout_minutes, 1440) # cap at 24 hours
lockout_until = datetime.now() + timedelta(minutes=lockout_minutes)
database.set_lockout(user["id"], lockout_until.isoformat())
return f"Too many failed attempts. Account locked for {lockout_minutes} minutes."
remaining = MAX_ATTEMPTS - new_attempts
return f"Wrong password. {remaining} attempt(s) remaining before lockout."
def validate_password_strength(password):
"""
Checks if a password meets minimum security requirements.
Returns (is_valid: bool, error_message: str)
"""
if len(password) < 8:
return False, "Password must be at least 8 characters."
if not any(c.isupper() for c in password):
return False, "Password must contain at least one uppercase letter."
if not any(c.islower() for c in password):
return False, "Password must contain at least one lowercase letter."
if not any(c.isdigit() for c in password):
return False, "Password must contain at least one number."
return True, ""
# Add to the bottom of security.py
import secrets # add this import at the TOP of security.py with other imports
def generate_reset_token():
"""
Generates a cryptographically secure random token for password reset.
secrets.token_urlsafe() uses the OS random number generator —
far more secure than random.random(). The result is URL-safe base64,
so it can go directly into a link without encoding issues.
64 bytes gives us a token so long that brute-forcing it is
computationally impossible (2^512 combinations).
"""
return secrets.token_urlsafe(64)
def validate_reset_token(user, token):
"""
Checks if a password reset token is valid and not expired.
Returns (is_valid: bool, error_message: str)
"""
if not user:
return False, "Invalid or expired reset link."
if not user["reset_token"] or user["reset_token"] != token:
return False, "Invalid or expired reset link."
if not user["reset_expires"]:
return False, "Invalid or expired reset link."
expires = datetime.fromisoformat(user["reset_expires"])
if datetime.now() > expires:
return False, "This reset link has expired. Please request a new one."
return True, ""
import hashlib # add this at the top of security.py with other imports
def hash_otp(otp_code):
"""
Hashes a plain OTP using SHA-256 before storing in the database.
We don't use bcrypt here because OTPs are already protected by:
- 10-minute expiry
- 3-attempt maximum
SHA-256 is instant and sufficient for this use case.
"""
return hashlib.sha256(otp_code.encode("utf-8")).hexdigest()
def verify_otp(entered_otp, stored_hash):
"""
Hashes the entered OTP and compares it to the stored hash.
Never compares plain OTP to the stored value directly.
Returns True if they match.
"""
return hashlib.sha256(entered_otp.encode("utf-8")).hexdigest() == stored_hash