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.
Files changed (128) hide show
  1. stk/__init__.py +19 -0
  2. stk/__main__.py +6 -0
  3. stk/agent_login.py +78 -0
  4. stk/app.py +323 -0
  5. stk/checks.py +634 -0
  6. stk/cli/__init__.py +49 -0
  7. stk/cli/agent.py +267 -0
  8. stk/cli/base.py +79 -0
  9. stk/cli/database.py +199 -0
  10. stk/cli/develop.py +63 -0
  11. stk/cli/doctor.py +262 -0
  12. stk/cli/main.py +101 -0
  13. stk/cli/reports.py +296 -0
  14. stk/cli/smoke.py +399 -0
  15. stk/cli/users.py +259 -0
  16. stk/cli/watch.py +68 -0
  17. stk/extensions.py +29 -0
  18. stk/migrations/__init__.py +130 -0
  19. stk/migrations/env.py +58 -0
  20. stk/migrations/script.py.mako +17 -0
  21. stk/migrations/versions/stk_0001_initial_schema.py +213 -0
  22. stk/portal/views.py +34 -0
  23. stk/public/__init__.py +0 -0
  24. stk/public/models.py +0 -0
  25. stk/public/views.py +344 -0
  26. stk/scaffold/__init__.py +1 -0
  27. stk/scaffold/app/CLAUDE.md +65 -0
  28. stk/scaffold/app/Dockerfile +41 -0
  29. stk/scaffold/app/README.md +16 -0
  30. stk/scaffold/app/__app__/__init__.py +1 -0
  31. stk/scaffold/app/__app__/app.py +8 -0
  32. stk/scaffold/app/__app__/settings.py +7 -0
  33. stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
  34. stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
  35. stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
  36. stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
  37. stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
  38. stk/scaffold/app/docker-compose.yml +88 -0
  39. stk/scaffold/app/dockerignore +13 -0
  40. stk/scaffold/app/env-sample +78 -0
  41. stk/scaffold/app/gitignore +8 -0
  42. stk/scaffold/app/nginx/nginx.conf +46 -0
  43. stk/scaffold/app/nginx/stk.conf +36 -0
  44. stk/scaffold/app/postgres/init-db.sh +8 -0
  45. stk/scaffold/app/pyproject.toml +23 -0
  46. stk/scaffold/app/tests/__init__.py +0 -0
  47. stk/scaffold/app/tests/test_app.py +23 -0
  48. stk/scaffold/generator.py +209 -0
  49. stk/scaffold/templates.py +437 -0
  50. stk/security.py +145 -0
  51. stk/settings.py +147 -0
  52. stk/shell.py +284 -0
  53. stk/static/VERSIONS.txt +4 -0
  54. stk/static/css/app.css +229 -0
  55. stk/static/css/layout.css +330 -0
  56. stk/static/css/vuetify.min.css +6 -0
  57. stk/static/icons/fonts/tabler-icons.woff2 +0 -0
  58. stk/static/icons/tabler-icons.min.css +4 -0
  59. stk/static/img/auth-bg.webp +0 -0
  60. stk/static/img/favicon.ico +0 -0
  61. stk/static/img/stk.svg +1664 -0
  62. stk/static/js/axios.min.js +5 -0
  63. stk/static/js/components/NotificationDropdown.js +156 -0
  64. stk/static/js/components/ThemeSwitcher.js +68 -0
  65. stk/static/js/components/TransitionExpand.js +64 -0
  66. stk/static/js/components/VerticalNavGroup.js +119 -0
  67. stk/static/js/components/VerticalNavLink.js +36 -0
  68. stk/static/js/components/VerticalNavSectionTitle.js +21 -0
  69. stk/static/js/components/index.js +47 -0
  70. stk/static/js/config.js +150 -0
  71. stk/static/js/vue.min.js +13 -0
  72. stk/static/js/vuetify.min.js +3148 -0
  73. stk/static/robots.txt +1 -0
  74. stk/tasks/__init__.py +78 -0
  75. stk/templates/401.html +14 -0
  76. stk/templates/404.html +12 -0
  77. stk/templates/500.html +13 -0
  78. stk/templates/_brand.html +1 -0
  79. stk/templates/auth_layout.html +26 -0
  80. stk/templates/cms/activities.html +135 -0
  81. stk/templates/cms/roles.html +215 -0
  82. stk/templates/cms/users.html +312 -0
  83. stk/templates/dashboard.html +132 -0
  84. stk/templates/index.html +52 -0
  85. stk/templates/layout.html +328 -0
  86. stk/templates/login-layout.html +54 -0
  87. stk/templates/security/_macros.html +39 -0
  88. stk/templates/security/_messages.html +12 -0
  89. stk/templates/security/change_password.html +100 -0
  90. stk/templates/security/confirm_email.html +44 -0
  91. stk/templates/security/email/change_notice.html +4 -0
  92. stk/templates/security/email/change_notice.txt +5 -0
  93. stk/templates/security/email/confirmation_instructions.html +3 -0
  94. stk/templates/security/email/confirmation_instructions.txt +3 -0
  95. stk/templates/security/email/login_instructions.html +5 -0
  96. stk/templates/security/email/login_instructions.txt +5 -0
  97. stk/templates/security/email/reset_instructions.html +1 -0
  98. stk/templates/security/email/reset_instructions.txt +3 -0
  99. stk/templates/security/email/reset_notice.html +1 -0
  100. stk/templates/security/email/reset_notice.txt +1 -0
  101. stk/templates/security/email/welcome.html +7 -0
  102. stk/templates/security/email/welcome.txt +7 -0
  103. stk/templates/security/forgot_password.html +63 -0
  104. stk/templates/security/login_user.html +206 -0
  105. stk/templates/security/mf_recovery.html +67 -0
  106. stk/templates/security/mf_recovery_codes.html +119 -0
  107. stk/templates/security/register_user.html +263 -0
  108. stk/templates/security/reset_password.html +53 -0
  109. stk/templates/security/send_confirmation.html +48 -0
  110. stk/templates/security/send_login.html +39 -0
  111. stk/templates/security/two_factor_select.html +78 -0
  112. stk/templates/security/two_factor_setup.html +166 -0
  113. stk/templates/security/two_factor_verify_code.html +71 -0
  114. stk/templates/security/wan_register.html +179 -0
  115. stk/templates/security/wan_signin.html +123 -0
  116. stk/templates/security/wan_verify.html +109 -0
  117. stk/user/__init__.py +0 -0
  118. stk/user/forms.py +10 -0
  119. stk/user/models.py +308 -0
  120. stk/user/views.py +339 -0
  121. stk/utils/email.py +32 -0
  122. stk/utils/ratelimit.py +67 -0
  123. stk/websocket.py +133 -0
  124. stkit-17.0.0.dist-info/METADATA +135 -0
  125. stkit-17.0.0.dist-info/RECORD +128 -0
  126. stkit-17.0.0.dist-info/WHEEL +4 -0
  127. stkit-17.0.0.dist-info/entry_points.txt +2 -0
  128. 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
+ )