Skip to content

Commit 5979f13

Browse files
committed
Add password creation/reset via token
1 parent f7b5b41 commit 5979f13

File tree

6 files changed

+999
-41
lines changed

6 files changed

+999
-41
lines changed

gefapi/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def process_result_value(self, value, dialect):
5353
)
5454
from gefapi.models.execution import Execution # noqa: E402
5555
from gefapi.models.execution_log import ExecutionLog # noqa: E402
56+
from gefapi.models.password_reset_token import PasswordResetToken # noqa: E402
5657
from gefapi.models.rate_limit_event import RateLimitEvent # noqa: E402
5758
from gefapi.models.refresh_token import RefreshToken # noqa: E402
5859
from gefapi.models.script import Script # noqa: E402
@@ -66,6 +67,7 @@ def process_result_value(self, value, dialect):
6667
"AdminBoundary1Unit",
6768
"Execution",
6869
"ExecutionLog",
70+
"PasswordResetToken",
6971
"RateLimitEvent",
7072
"RefreshToken",
7173
"Script",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""PASSWORD RESET TOKEN MODEL
2+
3+
Provides secure time-limited tokens for password reset functionality.
4+
Tokens expire after 1 hour and can only be used once.
5+
"""
6+
7+
import datetime
8+
import logging
9+
import secrets
10+
import uuid
11+
12+
from gefapi import db
13+
from gefapi.models import GUID
14+
15+
db.GUID = GUID
16+
17+
logger = logging.getLogger(__name__)
18+
19+
# Token expiry time in hours
20+
PASSWORD_RESET_TOKEN_EXPIRY_HOURS = 1
21+
22+
23+
class PasswordResetToken(db.Model):
24+
"""Password Reset Token Model
25+
26+
Stores secure tokens for password reset requests. Each token:
27+
- Has a 1-hour expiry window
28+
- Can only be used once
29+
- Is associated with a single user
30+
- Uses cryptographically secure random generation
31+
"""
32+
33+
id = db.Column(
34+
db.GUID(),
35+
default=lambda: str(uuid.uuid4()),
36+
primary_key=True,
37+
autoincrement=False,
38+
)
39+
# Cryptographically secure token (64 characters, URL-safe)
40+
token = db.Column(db.String(128), unique=True, nullable=False, index=True)
41+
user_id = db.Column(db.GUID(), db.ForeignKey("user.id"), nullable=False)
42+
created_at = db.Column(
43+
db.DateTime(), default=lambda: datetime.datetime.now(datetime.UTC)
44+
)
45+
expires_at = db.Column(db.DateTime(), nullable=False)
46+
used_at = db.Column(db.DateTime(), nullable=True) # Track when token was used
47+
48+
# Relationship to user
49+
user = db.relationship("User", backref=db.backref("password_reset_tokens"))
50+
51+
def __init__(self, user_id):
52+
self.user_id = user_id
53+
self.token = self._generate_secure_token()
54+
self.created_at = datetime.datetime.now(datetime.UTC)
55+
self.expires_at = self.created_at + datetime.timedelta(
56+
hours=PASSWORD_RESET_TOKEN_EXPIRY_HOURS
57+
)
58+
59+
def __repr__(self):
60+
return f"<PasswordResetToken user_id={self.user_id!r}>"
61+
62+
@staticmethod
63+
def _generate_secure_token():
64+
"""Generate a cryptographically secure URL-safe token."""
65+
return secrets.token_urlsafe(48)
66+
67+
def is_valid(self):
68+
"""Check if the token is valid (not expired and not used)."""
69+
now = datetime.datetime.now(datetime.UTC)
70+
return self.used_at is None and self.expires_at > now
71+
72+
def mark_used(self):
73+
"""Mark the token as used."""
74+
self.used_at = datetime.datetime.now(datetime.UTC)
75+
76+
@classmethod
77+
def get_valid_token(cls, token_string):
78+
"""Find a valid (unexpired, unused) token by its string value.
79+
80+
Args:
81+
token_string: The token string to look up
82+
83+
Returns:
84+
PasswordResetToken if found and valid, None otherwise
85+
"""
86+
token = cls.query.filter_by(token=token_string).first()
87+
if token and token.is_valid():
88+
return token
89+
return None
90+
91+
@classmethod
92+
def invalidate_user_tokens(cls, user_id):
93+
"""Invalidate all existing tokens for a user.
94+
95+
Called when creating a new reset token to ensure only one
96+
valid token exists per user at a time.
97+
"""
98+
now = datetime.datetime.now(datetime.UTC)
99+
cls.query.filter(
100+
cls.user_id == user_id,
101+
cls.used_at.is_(None),
102+
cls.expires_at > now,
103+
).update({"used_at": now}, synchronize_session=False)
104+
105+
@classmethod
106+
def cleanup_expired_tokens(cls, days_old=7):
107+
"""Remove tokens older than the specified number of days.
108+
109+
Args:
110+
days_old: Remove tokens older than this many days (default 7)
111+
112+
Returns:
113+
Number of tokens deleted
114+
"""
115+
cutoff = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days_old)
116+
result = cls.query.filter(cls.created_at < cutoff).delete(
117+
synchronize_session=False
118+
)
119+
db.session.commit()
120+
return result

gefapi/routes/api/v1/users.py

Lines changed: 120 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,20 @@ def create_user():
9595
- `403 Forbidden`: Insufficient privileges to create the requested role
9696
- `429 Too Many Requests`: Rate limit exceeded
9797
- `500 Internal Server Error`: User creation failed
98+
99+
**Query Parameters**:
100+
- `legacy`: If "true" (default), emails the password directly for backwards
101+
compatibility with the QGIS plugin. If "false", sends a password reset
102+
link instead (more secure).
98103
"""
99104
logger.info("[ROUTER]: Creating user")
100105
body = request.get_json()
106+
107+
# Check for legacy query parameter (defaults to true for backwards
108+
# compatibility with QGIS plugin)
109+
legacy_param = request.args.get("legacy", "true")
110+
legacy = legacy_param.lower() != "false"
111+
101112
if request.headers.get("Authorization", None) is not None:
102113

103114
@jwt_required()
@@ -114,7 +125,7 @@ def identity():
114125
else:
115126
body["role"] = "USER"
116127
try:
117-
user = UserService.create_user(body)
128+
user = UserService.create_user(body, legacy=legacy)
118129
except UserDuplicated as e:
119130
logger.error("[ROUTER]: " + e.message)
120131
return error(status=400, detail=e.message)
@@ -656,13 +667,27 @@ def recover_password(user):
656667
**Path Parameters**:
657668
- `user`: User identifier (email address or numeric ID)
658669
670+
**Query Parameters**:
671+
- `legacy`: (optional, default=true) Password recovery mode:
672+
- `true` (default): Legacy mode - generates new password and emails it
673+
directly. Maintained for backwards compatibility with older QGIS
674+
plugin versions.
675+
- `false`: Secure mode - sends a password reset link that expires after
676+
1 hour. Recommended for new integrations.
677+
659678
**Request**: No request body required
660679
661-
**Recovery Process**:
680+
**Recovery Process (legacy=true, default)**:
681+
1. Validates user exists
682+
2. Generates a new secure password
683+
3. Updates user's password in database
684+
4. Emails the new password to user
685+
686+
**Recovery Process (legacy=false)**:
662687
1. Validates user exists and account is active
663-
2. Generates secure password reset token with expiration
688+
2. Generates secure password reset token with 1-hour expiration
664689
3. Sends password recovery email with reset link
665-
4. Logs recovery attempt for security monitoring
690+
4. User clicks link and sets new password via /user/reset-password endpoint
666691
667692
**Success Response Schema**:
668693
```json
@@ -671,39 +696,30 @@ def recover_password(user):
671696
"id": "user-123",
672697
"email": "user@example.com",
673698
"name": "John Doe",
674-
"role": "USER",
675-
"recovery_initiated": true,
676-
"recovery_token_expires": "2025-01-15T16:00:00Z"
699+
"role": "USER"
677700
}
678701
}
679702
```
680703
681-
**Email Content**:
682-
- Secure reset link with time-limited token
683-
- Clear instructions for password reset process
684-
- Security notice about unsolicited requests
685-
- Link expiration time (typically 1-2 hours)
686-
687-
**Security Features**:
688-
- Rate limiting prevents email flooding attacks
689-
- Tokens expire automatically for security
690-
- Email delivery doesn't reveal if account exists (privacy)
691-
- Multiple recovery attempts are logged and monitored
692-
693-
**Use Cases**:
694-
- User forgot their password
695-
- Account recovery after security incident
696-
- Password reset for inactive accounts
697-
- Initial password setup (in some configurations)
704+
**Security Notes**:
705+
- Legacy mode (default) is DEPRECATED but maintained for backwards
706+
compatibility. It sends passwords via email which is less secure.
707+
- New integrations should use `legacy=false` for better security.
708+
- Rate limiting prevents email flooding attacks in both modes.
698709
699710
**Error Responses**:
700-
- `404 Not Found`: User does not exist (for security, may return success)
711+
- `404 Not Found`: User does not exist
701712
- `429 Too Many Requests`: Rate limit exceeded
702713
- `500 Internal Server Error`: Email delivery failed or system error
703714
"""
704715
logger.info("[ROUTER]: Recovering password")
716+
717+
# Parse legacy parameter - defaults to True for backwards compatibility
718+
legacy_param = request.args.get("legacy", "true").lower()
719+
use_legacy = legacy_param not in ("false", "0", "no")
720+
705721
try:
706-
user = UserService.recover_password(user)
722+
user = UserService.recover_password(user, legacy=use_legacy)
707723
except UserNotFound as e:
708724
logger.error("[ROUTER]: " + e.message)
709725
return error(status=404, detail=e.message)
@@ -716,6 +732,84 @@ def recover_password(user):
716732
return jsonify(data=user.serialize()), 200
717733

718734

735+
@endpoints.route("/user/reset-password", strict_slashes=False, methods=["POST"])
736+
@limiter.limit(
737+
lambda: ";".join(RateLimitConfig.get_password_reset_limits()) or "3 per hour",
738+
key_func=get_admin_aware_key,
739+
exempt_when=is_rate_limiting_disabled,
740+
)
741+
def reset_password_with_token():
742+
"""
743+
Reset password using a secure token from password recovery email.
744+
745+
**Rate Limited**: Subject to password recovery rate limits (configurable)
746+
**Access**: Public endpoint - no authentication required
747+
**Security**: Token-based authentication, tokens expire after 1 hour
748+
749+
**Request Body Schema**:
750+
```json
751+
{
752+
"token": "secure-reset-token-from-email",
753+
"password": "new-secure-password"
754+
}
755+
```
756+
757+
**Password Requirements**:
758+
- Minimum 8 characters
759+
- Must contain at least one uppercase letter
760+
- Must contain at least one lowercase letter
761+
- Must contain at least one digit
762+
763+
**Success Response Schema**:
764+
```json
765+
{
766+
"data": {
767+
"message": "Password reset successful"
768+
}
769+
}
770+
```
771+
772+
**Security Features**:
773+
- Tokens are single-use (marked as used after successful reset)
774+
- Tokens expire after 1 hour
775+
- Rate limiting prevents brute force attacks
776+
- Password strength validation enforced
777+
778+
**Error Responses**:
779+
- `400 Bad Request`: Missing token or password
780+
- `404 Not Found`: Invalid or expired token
781+
- `422 Unprocessable Entity`: Password doesn't meet requirements
782+
- `429 Too Many Requests`: Rate limit exceeded
783+
- `500 Internal Server Error`: System error
784+
"""
785+
logger.info("[ROUTER]: Reset password with token")
786+
try:
787+
body = request.get_json()
788+
if not body:
789+
return error(status=400, detail="Request body required")
790+
791+
token = body.get("token")
792+
password = body.get("password")
793+
794+
if not token:
795+
return error(status=400, detail="Reset token is required")
796+
if not password:
797+
return error(status=400, detail="New password is required")
798+
799+
UserService.reset_password_with_token(token, password)
800+
return jsonify(data={"message": "Password reset successful"}), 200
801+
802+
except UserNotFound as e:
803+
logger.error("[ROUTER]: " + e.message)
804+
return error(status=404, detail=e.message)
805+
except PasswordValidationError as e:
806+
logger.error("[ROUTER]: " + e.message)
807+
return error(status=422, detail=e.message)
808+
except Exception as e:
809+
logger.error("[ROUTER]: " + str(e))
810+
return error(status=500, detail="Generic Error")
811+
812+
719813
@endpoints.route("/user/<user>", strict_slashes=False, methods=["PATCH"])
720814
@jwt_required()
721815
@validate_user_update

0 commit comments

Comments
 (0)