stkit 17.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stk/__init__.py +19 -0
- stk/__main__.py +6 -0
- stk/agent_login.py +78 -0
- stk/app.py +323 -0
- stk/checks.py +634 -0
- stk/cli/__init__.py +49 -0
- stk/cli/agent.py +267 -0
- stk/cli/base.py +79 -0
- stk/cli/database.py +199 -0
- stk/cli/develop.py +63 -0
- stk/cli/doctor.py +262 -0
- stk/cli/main.py +101 -0
- stk/cli/reports.py +296 -0
- stk/cli/smoke.py +399 -0
- stk/cli/users.py +259 -0
- stk/cli/watch.py +68 -0
- stk/extensions.py +29 -0
- stk/migrations/__init__.py +130 -0
- stk/migrations/env.py +58 -0
- stk/migrations/script.py.mako +17 -0
- stk/migrations/versions/stk_0001_initial_schema.py +213 -0
- stk/portal/views.py +34 -0
- stk/public/__init__.py +0 -0
- stk/public/models.py +0 -0
- stk/public/views.py +344 -0
- stk/scaffold/__init__.py +1 -0
- stk/scaffold/app/CLAUDE.md +65 -0
- stk/scaffold/app/Dockerfile +41 -0
- stk/scaffold/app/README.md +16 -0
- stk/scaffold/app/__app__/__init__.py +1 -0
- stk/scaffold/app/__app__/app.py +8 -0
- stk/scaffold/app/__app__/settings.py +7 -0
- stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
- stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
- stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
- stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
- stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
- stk/scaffold/app/docker-compose.yml +88 -0
- stk/scaffold/app/dockerignore +13 -0
- stk/scaffold/app/env-sample +78 -0
- stk/scaffold/app/gitignore +8 -0
- stk/scaffold/app/nginx/nginx.conf +46 -0
- stk/scaffold/app/nginx/stk.conf +36 -0
- stk/scaffold/app/postgres/init-db.sh +8 -0
- stk/scaffold/app/pyproject.toml +23 -0
- stk/scaffold/app/tests/__init__.py +0 -0
- stk/scaffold/app/tests/test_app.py +23 -0
- stk/scaffold/generator.py +209 -0
- stk/scaffold/templates.py +437 -0
- stk/security.py +145 -0
- stk/settings.py +147 -0
- stk/shell.py +284 -0
- stk/static/VERSIONS.txt +4 -0
- stk/static/css/app.css +229 -0
- stk/static/css/layout.css +330 -0
- stk/static/css/vuetify.min.css +6 -0
- stk/static/icons/fonts/tabler-icons.woff2 +0 -0
- stk/static/icons/tabler-icons.min.css +4 -0
- stk/static/img/auth-bg.webp +0 -0
- stk/static/img/favicon.ico +0 -0
- stk/static/img/stk.svg +1664 -0
- stk/static/js/axios.min.js +5 -0
- stk/static/js/components/NotificationDropdown.js +156 -0
- stk/static/js/components/ThemeSwitcher.js +68 -0
- stk/static/js/components/TransitionExpand.js +64 -0
- stk/static/js/components/VerticalNavGroup.js +119 -0
- stk/static/js/components/VerticalNavLink.js +36 -0
- stk/static/js/components/VerticalNavSectionTitle.js +21 -0
- stk/static/js/components/index.js +47 -0
- stk/static/js/config.js +150 -0
- stk/static/js/vue.min.js +13 -0
- stk/static/js/vuetify.min.js +3148 -0
- stk/static/robots.txt +1 -0
- stk/tasks/__init__.py +78 -0
- stk/templates/401.html +14 -0
- stk/templates/404.html +12 -0
- stk/templates/500.html +13 -0
- stk/templates/_brand.html +1 -0
- stk/templates/auth_layout.html +26 -0
- stk/templates/cms/activities.html +135 -0
- stk/templates/cms/roles.html +215 -0
- stk/templates/cms/users.html +312 -0
- stk/templates/dashboard.html +132 -0
- stk/templates/index.html +52 -0
- stk/templates/layout.html +328 -0
- stk/templates/login-layout.html +54 -0
- stk/templates/security/_macros.html +39 -0
- stk/templates/security/_messages.html +12 -0
- stk/templates/security/change_password.html +100 -0
- stk/templates/security/confirm_email.html +44 -0
- stk/templates/security/email/change_notice.html +4 -0
- stk/templates/security/email/change_notice.txt +5 -0
- stk/templates/security/email/confirmation_instructions.html +3 -0
- stk/templates/security/email/confirmation_instructions.txt +3 -0
- stk/templates/security/email/login_instructions.html +5 -0
- stk/templates/security/email/login_instructions.txt +5 -0
- stk/templates/security/email/reset_instructions.html +1 -0
- stk/templates/security/email/reset_instructions.txt +3 -0
- stk/templates/security/email/reset_notice.html +1 -0
- stk/templates/security/email/reset_notice.txt +1 -0
- stk/templates/security/email/welcome.html +7 -0
- stk/templates/security/email/welcome.txt +7 -0
- stk/templates/security/forgot_password.html +63 -0
- stk/templates/security/login_user.html +206 -0
- stk/templates/security/mf_recovery.html +67 -0
- stk/templates/security/mf_recovery_codes.html +119 -0
- stk/templates/security/register_user.html +263 -0
- stk/templates/security/reset_password.html +53 -0
- stk/templates/security/send_confirmation.html +48 -0
- stk/templates/security/send_login.html +39 -0
- stk/templates/security/two_factor_select.html +78 -0
- stk/templates/security/two_factor_setup.html +166 -0
- stk/templates/security/two_factor_verify_code.html +71 -0
- stk/templates/security/wan_register.html +179 -0
- stk/templates/security/wan_signin.html +123 -0
- stk/templates/security/wan_verify.html +109 -0
- stk/user/__init__.py +0 -0
- stk/user/forms.py +10 -0
- stk/user/models.py +308 -0
- stk/user/views.py +339 -0
- stk/utils/email.py +32 -0
- stk/utils/ratelimit.py +67 -0
- stk/websocket.py +133 -0
- stkit-17.0.0.dist-info/METADATA +135 -0
- stkit-17.0.0.dist-info/RECORD +128 -0
- stkit-17.0.0.dist-info/WHEEL +4 -0
- stkit-17.0.0.dist-info/entry_points.txt +2 -0
- stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/user/models.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import secrets
|
|
3
|
+
import string
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from quart import g
|
|
8
|
+
from quart_security import RoleMixin, SecurityState, UserMixin, hash_password
|
|
9
|
+
from quart_security.password import hash_password_async
|
|
10
|
+
from sqlalchemy import (
|
|
11
|
+
JSON,
|
|
12
|
+
Boolean,
|
|
13
|
+
Column,
|
|
14
|
+
DateTime,
|
|
15
|
+
ForeignKey,
|
|
16
|
+
Integer,
|
|
17
|
+
LargeBinary,
|
|
18
|
+
String,
|
|
19
|
+
Table,
|
|
20
|
+
UniqueConstraint,
|
|
21
|
+
delete,
|
|
22
|
+
select,
|
|
23
|
+
)
|
|
24
|
+
from sqlalchemy.orm import declared_attr, relationship
|
|
25
|
+
|
|
26
|
+
from stk.extensions import Base
|
|
27
|
+
|
|
28
|
+
SecurityState.__table__.to_metadata(Base.metadata)
|
|
29
|
+
|
|
30
|
+
roles_users = Table(
|
|
31
|
+
"roles_users",
|
|
32
|
+
Base.metadata,
|
|
33
|
+
Column("user_id", Integer, ForeignKey("user.id"), primary_key=True),
|
|
34
|
+
Column("role_id", Integer, ForeignKey("role.id"), primary_key=True),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclasses.dataclass
|
|
39
|
+
class Role(Base, RoleMixin):
|
|
40
|
+
__tablename__ = "role"
|
|
41
|
+
|
|
42
|
+
id = Column(Integer, primary_key=True)
|
|
43
|
+
name = Column(String(80), unique=True, nullable=True)
|
|
44
|
+
description = Column(String(255), nullable=True)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {"id": self.id, "name": self.name, "description": self.description}
|
|
48
|
+
|
|
49
|
+
def from_dict(self, json_dict):
|
|
50
|
+
self.name = json_dict.get("name", self.name)
|
|
51
|
+
self.description = json_dict.get("description", self.description)
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclasses.dataclass
|
|
56
|
+
class User(Base, UserMixin):
|
|
57
|
+
__tablename__ = "user"
|
|
58
|
+
|
|
59
|
+
id = Column(Integer, primary_key=True)
|
|
60
|
+
username = Column(String(255), unique=True, nullable=True)
|
|
61
|
+
fs_uniquifier = Column(
|
|
62
|
+
String(255), unique=True, nullable=False, default=lambda: uuid4().hex
|
|
63
|
+
)
|
|
64
|
+
name = Column(String(255), nullable=True)
|
|
65
|
+
created_at = Column(DateTime, default=datetime.now, nullable=False)
|
|
66
|
+
email = Column(String(255), unique=True, nullable=False)
|
|
67
|
+
password = Column(String(255), nullable=False)
|
|
68
|
+
password_set = Column(Boolean, default=True, nullable=False)
|
|
69
|
+
active = Column(Boolean, default=False, nullable=True)
|
|
70
|
+
|
|
71
|
+
roles = relationship(
|
|
72
|
+
"Role", secondary=roles_users, backref="users", lazy="selectin"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# account lockout
|
|
76
|
+
failed_login_count = Column(Integer, default=0, nullable=False)
|
|
77
|
+
locked_until = Column(DateTime, nullable=True)
|
|
78
|
+
|
|
79
|
+
confirmed_at = Column(DateTime, nullable=True)
|
|
80
|
+
last_login_at = Column(DateTime, nullable=True)
|
|
81
|
+
current_login_at = Column(DateTime, nullable=True)
|
|
82
|
+
last_login_ip = Column(String(255), nullable=True)
|
|
83
|
+
current_login_ip = Column(String(255), nullable=True)
|
|
84
|
+
login_count = Column(Integer, nullable=True)
|
|
85
|
+
|
|
86
|
+
# web authn
|
|
87
|
+
fs_webauthn_user_handle = Column(String(64), unique=True, nullable=True)
|
|
88
|
+
tf_phone_number = Column(String(64), nullable=True)
|
|
89
|
+
tf_primary_method = Column(String(140), nullable=True)
|
|
90
|
+
tf_totp_secret = Column(String(255), nullable=True)
|
|
91
|
+
mf_recovery_codes = Column(JSON, nullable=True)
|
|
92
|
+
|
|
93
|
+
@declared_attr
|
|
94
|
+
def webauthn(cls):
|
|
95
|
+
return relationship(
|
|
96
|
+
"WebAuthn", backref="users", cascade="all, delete", lazy="selectin"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def display_name(self):
|
|
101
|
+
return self.name or self.email
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def has_usable_password(self):
|
|
105
|
+
return self.password_set
|
|
106
|
+
|
|
107
|
+
def to_dict(self):
|
|
108
|
+
return {
|
|
109
|
+
"id": self.id,
|
|
110
|
+
"active": self.active,
|
|
111
|
+
"name": self.name,
|
|
112
|
+
"email": self.email,
|
|
113
|
+
"roles": [role.to_dict() for role in self.roles],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async def from_dict(self, json_dict):
|
|
117
|
+
self.name = json_dict.get("name", self.name)
|
|
118
|
+
self.username = json_dict.get("username", self.username)
|
|
119
|
+
self.email = json_dict.get("email", self.email)
|
|
120
|
+
if "password" in json_dict:
|
|
121
|
+
self.password = await hash_password_async(json_dict["password"])
|
|
122
|
+
self.password_set = True
|
|
123
|
+
self.fs_uniquifier = uuid4().hex
|
|
124
|
+
if "roles" in json_dict:
|
|
125
|
+
role_ids = [r.get("id") for r in json_dict["roles"]]
|
|
126
|
+
result = await g.db_session.execute(
|
|
127
|
+
select(Role).where(Role.id.in_(role_ids))
|
|
128
|
+
)
|
|
129
|
+
self.roles = list(result.scalars().all())
|
|
130
|
+
self.active = json_dict.get("active", self.active)
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def __str__(self) -> str:
|
|
134
|
+
return f"{self.id}"
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return f"<User {self.id}: {self.email}>"
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def random_password(length=32):
|
|
141
|
+
alphabet = string.ascii_letters + string.digits + string.punctuation
|
|
142
|
+
password = "".join(secrets.choice(alphabet) for i in range(length))
|
|
143
|
+
return hash_password(password)
|
|
144
|
+
|
|
145
|
+
async def logout_other_sessions(self, current_session_token=None):
|
|
146
|
+
await Session.deactivate_user_sessions(
|
|
147
|
+
self.id, exclude_token=current_session_token
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def get_active_sessions(self):
|
|
151
|
+
return [s for s in self.sessions if s.is_active]
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def two_factor_devices(self):
|
|
155
|
+
devices = []
|
|
156
|
+
if self.tf_primary_method:
|
|
157
|
+
devices.append(
|
|
158
|
+
{"type": self.tf_primary_method, "name": "Authenticator App"}
|
|
159
|
+
)
|
|
160
|
+
if self.webauthn:
|
|
161
|
+
for wan in self.webauthn:
|
|
162
|
+
devices.append(
|
|
163
|
+
{"type": "webauthn", "name": wan.name, "usage": wan.usage}
|
|
164
|
+
)
|
|
165
|
+
return devices
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class WebAuthn(Base):
|
|
169
|
+
__tablename__ = "web_authn"
|
|
170
|
+
|
|
171
|
+
id = Column(Integer, primary_key=True)
|
|
172
|
+
credential_id = Column(LargeBinary(1024), index=True, nullable=False, unique=True)
|
|
173
|
+
public_key = Column(LargeBinary(1024), nullable=False)
|
|
174
|
+
sign_count = Column(Integer, default=0, nullable=False)
|
|
175
|
+
transports = Column(JSON, nullable=True)
|
|
176
|
+
extensions = Column(String(255), nullable=True)
|
|
177
|
+
lastuse_datetime = Column(DateTime, nullable=False)
|
|
178
|
+
name = Column(String(64), nullable=False)
|
|
179
|
+
usage = Column(String(64), nullable=False)
|
|
180
|
+
backup_state = Column(Boolean, nullable=False)
|
|
181
|
+
device_type = Column(String(64), nullable=False)
|
|
182
|
+
|
|
183
|
+
@declared_attr
|
|
184
|
+
def user_id(cls):
|
|
185
|
+
return Column(
|
|
186
|
+
String(64),
|
|
187
|
+
ForeignKey("user.fs_webauthn_user_handle", ondelete="CASCADE"),
|
|
188
|
+
nullable=False,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def get_user_mapping(self):
|
|
192
|
+
return {"fs_webauthn_user_handle": self.user_id}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class OAuth(Base):
|
|
196
|
+
__tablename__ = "oauth"
|
|
197
|
+
|
|
198
|
+
id = Column(Integer, primary_key=True)
|
|
199
|
+
provider = Column(String(50), nullable=False)
|
|
200
|
+
provider_user_id = Column(String(256), nullable=False)
|
|
201
|
+
token = Column(JSON, nullable=True)
|
|
202
|
+
created_at = Column(DateTime, default=datetime.now, nullable=False)
|
|
203
|
+
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
|
204
|
+
user = relationship(
|
|
205
|
+
"User",
|
|
206
|
+
backref="oauth_accounts",
|
|
207
|
+
lazy="selectin",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
__table_args__ = (
|
|
211
|
+
UniqueConstraint("provider", "provider_user_id", name="uq_oauth_provider_user"),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Activity(Base):
|
|
216
|
+
__tablename__ = "activity"
|
|
217
|
+
|
|
218
|
+
id = Column(Integer, primary_key=True)
|
|
219
|
+
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
|
220
|
+
action = Column(String(255), nullable=False)
|
|
221
|
+
data = Column(JSON, nullable=True)
|
|
222
|
+
created_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
async def register(cls, user_id, action, data=None):
|
|
226
|
+
activity = cls(user_id=user_id, action=action, data=data)
|
|
227
|
+
g.db_session.add(activity)
|
|
228
|
+
return activity
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class Session(Base):
|
|
232
|
+
__tablename__ = "user_sessions"
|
|
233
|
+
|
|
234
|
+
id = Column(Integer, primary_key=True)
|
|
235
|
+
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
|
236
|
+
user = relationship("User", backref="sessions", lazy="selectin")
|
|
237
|
+
|
|
238
|
+
session_token = Column(String(255), unique=True, nullable=False)
|
|
239
|
+
last_active = Column(DateTime, default=datetime.now)
|
|
240
|
+
expires_at = Column(DateTime, nullable=True)
|
|
241
|
+
ip_address = Column(String(255), nullable=True)
|
|
242
|
+
|
|
243
|
+
meta = Column(JSON, nullable=True)
|
|
244
|
+
|
|
245
|
+
is_active = Column(Boolean, default=True, index=True)
|
|
246
|
+
created_at = Column(DateTime, default=datetime.now, nullable=False)
|
|
247
|
+
|
|
248
|
+
def to_dict(self):
|
|
249
|
+
return {
|
|
250
|
+
"id": self.id,
|
|
251
|
+
"user_id": self.user_id,
|
|
252
|
+
"last_active": self.last_active.isoformat() if self.last_active else None,
|
|
253
|
+
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
254
|
+
"ip_address": self.ip_address,
|
|
255
|
+
"meta": self.meta,
|
|
256
|
+
"is_active": self.is_active,
|
|
257
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
async def create_session(cls, user_id, session_token, ip_address=None, meta=None):
|
|
262
|
+
result = await g.db_session.execute(
|
|
263
|
+
select(cls).filter_by(session_token=session_token)
|
|
264
|
+
)
|
|
265
|
+
existing = result.scalar_one_or_none()
|
|
266
|
+
if existing:
|
|
267
|
+
existing.user_id = user_id
|
|
268
|
+
existing.ip_address = ip_address
|
|
269
|
+
existing.meta = meta
|
|
270
|
+
existing.is_active = True
|
|
271
|
+
existing.last_active = datetime.now()
|
|
272
|
+
g.db_session.add(existing)
|
|
273
|
+
return existing
|
|
274
|
+
|
|
275
|
+
session_record = cls(
|
|
276
|
+
user_id=user_id,
|
|
277
|
+
session_token=session_token,
|
|
278
|
+
ip_address=ip_address,
|
|
279
|
+
meta=meta,
|
|
280
|
+
is_active=True,
|
|
281
|
+
)
|
|
282
|
+
g.db_session.add(session_record)
|
|
283
|
+
return session_record
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
async def deactivate_user_sessions(cls, user_id, exclude_token=None):
|
|
287
|
+
tokens = select(cls.session_token).where(cls.user_id == user_id)
|
|
288
|
+
if exclude_token:
|
|
289
|
+
tokens = tokens.where(cls.session_token != exclude_token)
|
|
290
|
+
await g.db_session.execute(
|
|
291
|
+
delete(SecurityState).where(SecurityState.token.in_(tokens))
|
|
292
|
+
)
|
|
293
|
+
stmt = (
|
|
294
|
+
cls.__table__.update()
|
|
295
|
+
.where(cls.user_id == user_id)
|
|
296
|
+
.where(cls.is_active == True) # noqa: E712
|
|
297
|
+
)
|
|
298
|
+
if exclude_token:
|
|
299
|
+
stmt = stmt.where(cls.session_token != exclude_token)
|
|
300
|
+
stmt = stmt.values(is_active=False)
|
|
301
|
+
await g.db_session.execute(stmt)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class RateLimitWindow(Base):
|
|
305
|
+
__tablename__ = "rate_limit_window"
|
|
306
|
+
key = Column(String(160), primary_key=True)
|
|
307
|
+
count = Column(Integer, nullable=False)
|
|
308
|
+
expires_at = Column(Integer, nullable=False, index=True)
|
stk/user/views.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
import orjson as json
|
|
6
|
+
from quart import Blueprint, Response, current_app, g, render_template, request, session
|
|
7
|
+
from quart_security import (
|
|
8
|
+
auth_required,
|
|
9
|
+
current_user,
|
|
10
|
+
password_changed,
|
|
11
|
+
roles_required,
|
|
12
|
+
tf_profile_changed,
|
|
13
|
+
user_authenticated,
|
|
14
|
+
user_logged_out,
|
|
15
|
+
)
|
|
16
|
+
from sqlalchemy import func, select
|
|
17
|
+
|
|
18
|
+
from stk.security import check_password_policy
|
|
19
|
+
from stk.user.models import Activity, Role, Session, User
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
bp_user = Blueprint("users", __name__, static_folder="../static")
|
|
24
|
+
|
|
25
|
+
PER_PAGE = 25
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@bp_user.before_request
|
|
29
|
+
@auth_required("session")
|
|
30
|
+
@roles_required("admin")
|
|
31
|
+
async def before_request():
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@bp_user.route("/users/")
|
|
36
|
+
async def users():
|
|
37
|
+
result = await g.db_session.execute(select(Role))
|
|
38
|
+
roles = [r.to_dict() for r in result.scalars().all()]
|
|
39
|
+
return await render_template("cms/users.html", roles=roles)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@bp_user.route("/api/users")
|
|
43
|
+
async def api_user():
|
|
44
|
+
page = request.args.get("page", 1, type=int)
|
|
45
|
+
per_page = request.args.get("per_page", PER_PAGE, type=int)
|
|
46
|
+
|
|
47
|
+
query = select(User)
|
|
48
|
+
count_result = await g.db_session.execute(select(func.count()).select_from(User))
|
|
49
|
+
total = count_result.scalar()
|
|
50
|
+
|
|
51
|
+
result = await g.db_session.execute(
|
|
52
|
+
query.offset((page - 1) * per_page).limit(per_page)
|
|
53
|
+
)
|
|
54
|
+
items = [user.to_dict() for user in result.scalars().all()]
|
|
55
|
+
|
|
56
|
+
response_data = {"items": items, "total": total, "perPage": per_page}
|
|
57
|
+
return Response(json.dumps(response_data), content_type="application/json")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@bp_user.post("/api/user/")
|
|
61
|
+
async def api_user_create():
|
|
62
|
+
json_data = await request.json
|
|
63
|
+
user_data = json_data.get("item", {})
|
|
64
|
+
if "password" in user_data:
|
|
65
|
+
await check_password_policy(user_data["password"])
|
|
66
|
+
user = User()
|
|
67
|
+
await user.from_dict(user_data)
|
|
68
|
+
user.confirmed_at = datetime.datetime.now()
|
|
69
|
+
g.db_session.add(user)
|
|
70
|
+
try:
|
|
71
|
+
await g.db_session.flush()
|
|
72
|
+
await g.db_session.refresh(user, ["roles"])
|
|
73
|
+
await Activity.register(current_user.id, "User Create", user.to_dict())
|
|
74
|
+
await g.db_session.commit()
|
|
75
|
+
return {"message": "User successfully created!"}
|
|
76
|
+
except Exception:
|
|
77
|
+
await g.db_session.rollback()
|
|
78
|
+
log.exception("Error creating user")
|
|
79
|
+
return {"message": "Error creating user"}, 412
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@bp_user.post("/api/user/<int:id>")
|
|
83
|
+
async def api_user_update(id):
|
|
84
|
+
user = await g.db_session.get(User, id)
|
|
85
|
+
if user is None:
|
|
86
|
+
return {"message": "User not found"}, 404
|
|
87
|
+
json_data = await request.json
|
|
88
|
+
user_data = json_data.get("item", {})
|
|
89
|
+
if "password" in user_data:
|
|
90
|
+
await check_password_policy(user_data["password"])
|
|
91
|
+
old_user_data = user.to_dict()
|
|
92
|
+
try:
|
|
93
|
+
await user.from_dict(user_data)
|
|
94
|
+
await g.db_session.flush()
|
|
95
|
+
await g.db_session.refresh(user, ["roles"])
|
|
96
|
+
await Activity.register(
|
|
97
|
+
current_user.id,
|
|
98
|
+
"User Update",
|
|
99
|
+
{"old": old_user_data, "new": user.to_dict()},
|
|
100
|
+
)
|
|
101
|
+
await g.db_session.commit()
|
|
102
|
+
return {"message": "User successfully updated!"}
|
|
103
|
+
except Exception:
|
|
104
|
+
await g.db_session.rollback()
|
|
105
|
+
log.exception("Error updating user")
|
|
106
|
+
return {"message": "Error updating user"}, 412
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@bp_user.post("/api/user/<int:id>/reset-password")
|
|
110
|
+
async def api_user_reset_password(id):
|
|
111
|
+
user = await g.db_session.get(User, id)
|
|
112
|
+
if user is None:
|
|
113
|
+
return {"message": "User not found"}, 404
|
|
114
|
+
json_data = await request.json
|
|
115
|
+
password = json_data.get("password", "")
|
|
116
|
+
await check_password_policy(password)
|
|
117
|
+
from quart_security.password import hash_password_async
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
user.password = await hash_password_async(password)
|
|
121
|
+
user.password_set = True
|
|
122
|
+
user.fs_uniquifier = uuid4().hex
|
|
123
|
+
await Activity.register(
|
|
124
|
+
current_user.id,
|
|
125
|
+
"Admin Password Reset",
|
|
126
|
+
{"target_user": user.email},
|
|
127
|
+
)
|
|
128
|
+
await g.db_session.commit()
|
|
129
|
+
return {"message": "Password reset successfully"}
|
|
130
|
+
except Exception:
|
|
131
|
+
await g.db_session.rollback()
|
|
132
|
+
log.exception("Error resetting password")
|
|
133
|
+
return {"message": "Error resetting password"}, 412
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@bp_user.route("/api/user/<int:id>", methods=["DELETE"])
|
|
137
|
+
async def api_user_delete(id):
|
|
138
|
+
user = await g.db_session.get(User, id)
|
|
139
|
+
if user is None:
|
|
140
|
+
return {"message": "User not found"}, 404
|
|
141
|
+
user_data = user.to_dict()
|
|
142
|
+
try:
|
|
143
|
+
await g.db_session.delete(user)
|
|
144
|
+
await Activity.register(current_user.id, "User Delete", user_data)
|
|
145
|
+
await g.db_session.commit()
|
|
146
|
+
return {"message": "User successfully deleted!"}
|
|
147
|
+
except Exception:
|
|
148
|
+
await g.db_session.rollback()
|
|
149
|
+
log.exception("Error deleting user")
|
|
150
|
+
return {"message": "Error deleting user"}, 412
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@bp_user.route("/roles/")
|
|
154
|
+
async def roles():
|
|
155
|
+
return await render_template("cms/roles.html")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@bp_user.route("/api/roles", methods=["GET"])
|
|
159
|
+
async def api_roles():
|
|
160
|
+
page = request.args.get("page", 1, type=int)
|
|
161
|
+
per_page = request.args.get("per_page", PER_PAGE, type=int)
|
|
162
|
+
|
|
163
|
+
count_result = await g.db_session.execute(select(func.count()).select_from(Role))
|
|
164
|
+
total = count_result.scalar()
|
|
165
|
+
|
|
166
|
+
result = await g.db_session.execute(
|
|
167
|
+
select(Role).offset((page - 1) * per_page).limit(per_page)
|
|
168
|
+
)
|
|
169
|
+
items = [role.to_dict() for role in result.scalars().all()]
|
|
170
|
+
|
|
171
|
+
response_data = {"items": items, "total": total, "perPage": per_page}
|
|
172
|
+
return Response(json.dumps(response_data), content_type="application/json")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@bp_user.route("/api/role/", methods=["POST"])
|
|
176
|
+
async def api_role_create():
|
|
177
|
+
json_data = await request.json
|
|
178
|
+
role_data = json_data.get("item", {})
|
|
179
|
+
role = Role()
|
|
180
|
+
role.from_dict(role_data)
|
|
181
|
+
g.db_session.add(role)
|
|
182
|
+
try:
|
|
183
|
+
await g.db_session.flush()
|
|
184
|
+
await Activity.register(current_user.id, "Role Create", role.to_dict())
|
|
185
|
+
await g.db_session.commit()
|
|
186
|
+
return {"message": "Role successfully created!"}
|
|
187
|
+
except Exception:
|
|
188
|
+
await g.db_session.rollback()
|
|
189
|
+
log.exception("Error creating role")
|
|
190
|
+
return {"message": "Error creating role"}, 412
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@bp_user.post("/api/role/<int:id>")
|
|
194
|
+
async def api_role_update(id):
|
|
195
|
+
role = await g.db_session.get(Role, id)
|
|
196
|
+
if role is None:
|
|
197
|
+
return {"message": "Role not found"}, 404
|
|
198
|
+
old_role_data = role.to_dict()
|
|
199
|
+
json_data = await request.json
|
|
200
|
+
role_data = json_data.get("item", {})
|
|
201
|
+
try:
|
|
202
|
+
role.from_dict(role_data)
|
|
203
|
+
await Activity.register(
|
|
204
|
+
current_user.id,
|
|
205
|
+
"Role Update",
|
|
206
|
+
{"old": old_role_data, "new": role.to_dict()},
|
|
207
|
+
)
|
|
208
|
+
await g.db_session.commit()
|
|
209
|
+
return {"message": "Role successfully updated!"}
|
|
210
|
+
except Exception:
|
|
211
|
+
await g.db_session.rollback()
|
|
212
|
+
log.exception("Error updating role")
|
|
213
|
+
return {"message": "Error updating role"}, 412
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@bp_user.route("/api/role/<int:id>", methods=["DELETE"])
|
|
217
|
+
async def api_role_delete(id):
|
|
218
|
+
role = await g.db_session.get(Role, id)
|
|
219
|
+
if role is None:
|
|
220
|
+
return {"message": "Role not found"}, 404
|
|
221
|
+
role_data = role.to_dict()
|
|
222
|
+
try:
|
|
223
|
+
await g.db_session.delete(role)
|
|
224
|
+
await Activity.register(current_user.id, "Role Delete", role_data)
|
|
225
|
+
await g.db_session.commit()
|
|
226
|
+
return {"message": "Role successfully deleted!"}
|
|
227
|
+
except Exception:
|
|
228
|
+
await g.db_session.rollback()
|
|
229
|
+
log.exception("Error deleting role")
|
|
230
|
+
return {"message": "Error deleting role"}, 412
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@bp_user.route("/activities/")
|
|
234
|
+
async def activities():
|
|
235
|
+
return await render_template("cms/activities.html")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@bp_user.route("/api/activities")
|
|
239
|
+
async def api_activities():
|
|
240
|
+
page = request.args.get("page", 1, type=int)
|
|
241
|
+
per_page = request.args.get("per_page", PER_PAGE, type=int)
|
|
242
|
+
|
|
243
|
+
count_result = await g.db_session.execute(
|
|
244
|
+
select(func.count()).select_from(Activity)
|
|
245
|
+
)
|
|
246
|
+
total = count_result.scalar()
|
|
247
|
+
|
|
248
|
+
result = await g.db_session.execute(
|
|
249
|
+
select(Activity, User)
|
|
250
|
+
.outerjoin(User, Activity.user_id == User.id)
|
|
251
|
+
.order_by(Activity.created_at.desc())
|
|
252
|
+
.offset((page - 1) * per_page)
|
|
253
|
+
.limit(per_page)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
items = []
|
|
257
|
+
for activity, user in result.all():
|
|
258
|
+
items.append(
|
|
259
|
+
{
|
|
260
|
+
"id": activity.id,
|
|
261
|
+
"user": user.display_name if user else f"User ID: {activity.user_id}",
|
|
262
|
+
"action": activity.action,
|
|
263
|
+
"data": activity.data,
|
|
264
|
+
"created_at": activity.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
response_data = {"items": items, "total": total, "perPage": per_page}
|
|
269
|
+
return Response(json.dumps(response_data), content_type="application/json")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --- Signal Handlers ---
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@user_authenticated.connect
|
|
276
|
+
async def user_authenticated_handler(app, user, authn_via, **extra_args):
|
|
277
|
+
"""Handle user authentication - create session record and check for new IP."""
|
|
278
|
+
session_data = {
|
|
279
|
+
"user_id": user.id,
|
|
280
|
+
"session_token": session["_id"],
|
|
281
|
+
"ip_address": request.remote_addr,
|
|
282
|
+
"meta": {
|
|
283
|
+
"browser": request.user_agent.browser,
|
|
284
|
+
"browser_version": request.user_agent.version,
|
|
285
|
+
"os": request.user_agent.platform,
|
|
286
|
+
"device": request.user_agent.string,
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await Session.create_session(**session_data)
|
|
291
|
+
await g.db_session.commit()
|
|
292
|
+
|
|
293
|
+
if user.last_login_ip and user.current_login_ip != user.last_login_ip:
|
|
294
|
+
await Activity.register(
|
|
295
|
+
user.id,
|
|
296
|
+
"Login from new IP",
|
|
297
|
+
{"old_ip": user.last_login_ip, "new_ip": user.current_login_ip},
|
|
298
|
+
)
|
|
299
|
+
await g.db_session.commit()
|
|
300
|
+
|
|
301
|
+
if current_app.config.get("DISABLE_MULTIPLE_SESSIONS", False):
|
|
302
|
+
await user.logout_other_sessions(session_data["session_token"])
|
|
303
|
+
await g.db_session.commit()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@password_changed.connect
|
|
307
|
+
async def after_password_change(sender, user, **extra_args):
|
|
308
|
+
"""Log password change and mark password as user-set."""
|
|
309
|
+
user.password_set = True
|
|
310
|
+
g.db_session.add(user)
|
|
311
|
+
await Activity.register(user.id, "Password Changed", {"email": user.email})
|
|
312
|
+
await g.db_session.commit()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@tf_profile_changed.connect
|
|
316
|
+
async def after_tf_profile_change(sender, user, **extra_args):
|
|
317
|
+
"""Log 2FA profile changes."""
|
|
318
|
+
await Activity.register(
|
|
319
|
+
user.id,
|
|
320
|
+
"Two-Factor Profile Changed",
|
|
321
|
+
{"email": user.email, "method": user.tf_primary_method},
|
|
322
|
+
)
|
|
323
|
+
await g.db_session.commit()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@user_logged_out.connect
|
|
327
|
+
async def user_logged_out_handler(app, user, **extra_args):
|
|
328
|
+
"""Clear session on logout."""
|
|
329
|
+
token = session.get("_id")
|
|
330
|
+
if token:
|
|
331
|
+
stmt = (
|
|
332
|
+
Session.__table__.update()
|
|
333
|
+
.where(Session.session_token == token)
|
|
334
|
+
.where(Session.is_active == True) # noqa: E712
|
|
335
|
+
.values(is_active=False)
|
|
336
|
+
)
|
|
337
|
+
await g.db_session.execute(stmt)
|
|
338
|
+
await g.db_session.commit()
|
|
339
|
+
session.clear()
|
stk/utils/email.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Async email utilities using aiosmtplib."""
|
|
2
|
+
|
|
3
|
+
from email.message import EmailMessage
|
|
4
|
+
|
|
5
|
+
import aiosmtplib
|
|
6
|
+
from quart import current_app
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def send_email(subject, recipient, body, html=None, sender=None):
|
|
10
|
+
"""Send an email asynchronously using aiosmtplib."""
|
|
11
|
+
app = current_app._get_current_object()
|
|
12
|
+
sender = sender or app.config.get("SECURITY_EMAIL_SENDER", "noreply@localhost")
|
|
13
|
+
if isinstance(sender, tuple):
|
|
14
|
+
sender = f"{sender[0]} <{sender[1]}>"
|
|
15
|
+
|
|
16
|
+
msg = EmailMessage()
|
|
17
|
+
msg["Subject"] = subject
|
|
18
|
+
msg["From"] = str(sender)
|
|
19
|
+
msg["To"] = recipient
|
|
20
|
+
msg.set_content(body)
|
|
21
|
+
if html:
|
|
22
|
+
msg.add_alternative(html, subtype="html")
|
|
23
|
+
|
|
24
|
+
await aiosmtplib.send(
|
|
25
|
+
msg,
|
|
26
|
+
hostname=app.config.get("MAIL_SERVER", "localhost"),
|
|
27
|
+
port=app.config.get("MAIL_PORT", 465),
|
|
28
|
+
username=app.config.get("MAIL_USERNAME"),
|
|
29
|
+
password=app.config.get("MAIL_PASSWORD"),
|
|
30
|
+
use_tls=app.config.get("MAIL_USE_SSL", False),
|
|
31
|
+
start_tls=app.config.get("MAIL_USE_TLS", False),
|
|
32
|
+
)
|