-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaedis_auth.py
More file actions
252 lines (207 loc) · 9.95 KB
/
Copy pathaedis_auth.py
File metadata and controls
252 lines (207 loc) · 9.95 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
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# aedis_auth.py
"""
Real account authentication for the AEDIS portal.
Flask session-based. No JWT, no OAuth, no external dependencies beyond
werkzeug (already a Flask dependency). Simple, auditable, self-contained —
appropriate for a local installer that may eventually need to be handed to
a sysadmin with limited Python experience.
Design principles carried from the rest of the registration system:
- Errors are always specific. "Email not found" and "Wrong password" are
the same error message to the caller (prevents account enumeration), but
the distinction is logged server-side so a reviewer can tell the difference.
- An account is required to SUBMIT an application, but not to start one.
The claim-token system still works for starting a draft on any device
without signing in. create_account() or link_draft_to_account() ties the
draft to an account when the user eventually creates one.
- account_type controls what the logged-in user can access (see Account
model). The required_role() decorator enforces this at the route level.
SESSION KEYS:
session['account_id'] str(uuid) set on login, cleared on logout
session['account_type'] str copied in for fast role checks without
a DB hit on every protected request
"""
import secrets
import os
from datetime import datetime, timedelta
from functools import wraps
from flask import session, request, jsonify
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy import func
from extensions import db
from models_registration import Account, Organization, Individual
# ---------------------------------------------------------------------------
# Password policy (simple but real)
# ---------------------------------------------------------------------------
MIN_PASSWORD_LENGTH = 10
def _validate_password(pw):
if not pw or len(pw) < MIN_PASSWORD_LENGTH:
raise ValueError(f"Password must be at least {MIN_PASSWORD_LENGTH} characters.")
if pw.lower() in ("password", "1234567890", "aedis12345", "qwertyuiop"):
raise ValueError("That password is too common. Please choose a stronger one.")
# ---------------------------------------------------------------------------
# Account creation
# ---------------------------------------------------------------------------
def create_account(email, password, full_name, account_type="registrant"):
"""Create a new Account. Raises ValueError with a user-facing message
on anything invalid. Returns the new Account on success."""
email = (email or "").strip().lower()
if not email or "@" not in email or "." not in email.split("@")[-1]:
raise ValueError("A valid email address is required.")
if not full_name or not full_name.strip():
raise ValueError("Full name is required.")
_validate_password(password)
if account_type not in ("registrant", "reviewer", "admin"):
raise ValueError(f"Unknown account type: {account_type}")
existing = Account.query.filter(
func.lower(Account.email) == email
).first()
if existing:
raise ValueError(
"An account with that email address already exists. "
"Log in or use 'Forgot password' if you can't get in."
)
require_verify = os.environ.get("AEDIS_REQUIRE_EMAIL_VERIFY", "0") == "1"
verify_token = secrets.token_urlsafe(32) if require_verify else None
account = Account(
email=email,
password_hash=generate_password_hash(password),
full_name=full_name.strip(),
account_type=account_type,
email_verified=not require_verify,
email_verify_token=verify_token,
)
db.session.add(account)
db.session.commit()
return account
# ---------------------------------------------------------------------------
# Login / logout / session
# ---------------------------------------------------------------------------
def login(email, password):
"""Validates credentials and sets the Flask session. Returns the Account.
Raises ValueError with a deliberately vague message (prevents account
enumeration) if either the email or password is wrong."""
email = (email or "").strip().lower()
account = Account.query.filter(func.lower(Account.email) == email).first()
# check_password_hash even on a dummy hash so timing is consistent
if not account:
check_password_hash("dummy$hash$to$prevent$timing$attack", password or "")
raise ValueError("Email or password is incorrect.")
if not check_password_hash(account.password_hash, password or ""):
raise ValueError("Email or password is incorrect.")
if not account.email_verified:
raise ValueError(
"Your email address hasn't been verified yet. "
"Check your inbox for the verification link."
)
account.last_login_at = datetime.utcnow()
db.session.commit()
session.permanent = True
session['account_id'] = str(account.id)
session['account_type'] = account.account_type
session['account_name'] = account.full_name
return account
def logout():
session.clear()
def current_account():
"""Returns the Account for the current session, or None if not logged in.
Deliberately avoids raising — callers check the return value."""
account_id = session.get('account_id')
if not account_id:
return None
return Account.query.get(account_id)
def require_role(*allowed_types):
"""Route decorator. Usage:
@require_role('admin')
@require_role('reviewer', 'admin')
Returns 401 if not logged in, 403 if logged in but wrong type."""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
acct = current_account()
if not acct:
return jsonify({'error': 'Login required.'}), 401
if acct.account_type not in allowed_types:
return jsonify({'error': 'Access denied.'}), 403
return fn(*args, **kwargs)
return wrapper
return decorator
# ---------------------------------------------------------------------------
# Linking claim-token drafts to an account on login/register
# ---------------------------------------------------------------------------
def link_draft_to_account(claim_token, account_id):
"""When a user creates an account or logs in while holding a draft
application (identified by its claim_token, stored in localStorage),
this ties that draft to their account so they can continue it from
any device. Safe to call with a nonexistent token (returns 0 without
error - the user just didn't have a pending draft)."""
count = 0
for org in Organization.query.filter_by(claim_token=claim_token).all():
if org.status in ("draft", "documents_requested"):
org.account_id = account_id
count += 1
for ind in Individual.query.filter_by(claim_token=claim_token).all():
if ind.status in ("draft", "documents_requested"):
ind.account_id = account_id
count += 1
if count:
db.session.commit()
return count
# ---------------------------------------------------------------------------
# Account dashboard data
# ---------------------------------------------------------------------------
def account_dashboard(account_id):
"""Everything the account holder needs to see on their dashboard:
their active/pending organization applications plus their own
individual registrations (for standalone practitioners/workers)."""
account = Account.query.get(account_id)
if not account:
raise ValueError("Account not found.")
orgs = Organization.query.filter_by(account_id=account_id).all()
inds = Individual.query.filter(
Individual.account_id == account_id,
Individual.organization_id.is_(None),
).all()
return {
'account': account.to_dict(),
'organizations': [
o.to_dict(include_individuals=False, include_credentials=True)
for o in orgs
],
'individuals': [i.to_dict() for i in inds],
'summary': {
'total_applications': len(orgs) + len(inds),
'active': sum(1 for o in orgs if o.status == 'active') +
sum(1 for i in inds if i.status == 'active'),
'drafts': sum(1 for o in orgs if o.status == 'draft') +
sum(1 for i in inds if i.status == 'draft'),
'pending_review': sum(1 for o in orgs if o.status in ('submitted', 'under_review')) +
sum(1 for i in inds if i.status in ('submitted', 'under_review')),
'action_required': sum(1 for o in orgs if o.status == 'documents_requested') +
sum(1 for i in inds if i.status == 'documents_requested'),
},
}
# ---------------------------------------------------------------------------
# Email verification
# ---------------------------------------------------------------------------
def verify_email_token(token):
"""Marks the account as email_verified. Returns the Account on success.
Raises ValueError if the token is invalid or already used."""
account = Account.query.filter_by(email_verify_token=token).first()
if not account:
raise ValueError("Invalid or expired verification link.")
account.email_verified = True
account.email_verify_token = None
db.session.commit()
return account
# ---------------------------------------------------------------------------
# Password change
# ---------------------------------------------------------------------------
def change_password(account_id, current_password, new_password):
account = Account.query.get(account_id)
if not account:
raise ValueError("Account not found.")
if not check_password_hash(account.password_hash, current_password):
raise ValueError("Current password is incorrect.")
_validate_password(new_password)
account.password_hash = generate_password_hash(new_password)
db.session.commit()