Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "client",
"version": "0.10.1",
"version": "0.11.0",
"private": true,
"scripts": {
"build": "vite build",
Expand Down
17 changes: 17 additions & 0 deletions client/src/store/modules/user/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,29 @@ export default {
body: JSON.stringify(user),
});
if (response.ok) {
await context.dispatch('GET_USERS');
Vue.$toast.success('User created!');
} else {
log.error('Unable to create user');
Vue.$toast.error('Unable to create user');
}
},
async DELETE_USER(context, userId) {
const response = await fetch(`${makeURL('/api/v1/auth/delete')}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: userId }),
});
if (response.ok) {
await context.dispatch('GET_USERS');
Vue.$toast.success('User deleted!');
} else {
log.error('Unable to delete user');
Vue.$toast.error('Unable to delete user');
}
},
async USER_LOGIN(context, user) {
const response = await fetch(`${makeURL('/api/v1/auth/login')}`, {
method: 'POST',
Expand Down
6 changes: 2 additions & 4 deletions client/src/store/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,8 @@ export default new Vuex.Store({
},
async SHOW_CHANGED(context) {
if (context.rootGetters.CURRENT_USER != null) {
const response = await fetch(`${makeURL('/api/v1/auth/validate')}`);
if (response.status === 401) {
await context.dispatch('USER_LOGOUT');
}
await context.dispatch('GET_CURRENT_USER');
await context.dispatch('GET_CURRENT_RBAC');
}
window.location.reload();
},
Expand Down
16 changes: 15 additions & 1 deletion client/src/vue_components/config/ConfigUsers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
>
RBAC
</b-button>
<b-button
variant="danger"
:disabled="data.item.is_admin"
@click.stop="deleteUser(data)"
>
Delete
</b-button>
</b-button-group>
</template>
</b-table>
Expand Down Expand Up @@ -89,7 +96,14 @@ export default {
setEditUser(userId) {
this.editUser = userId;
},
...mapActions(['GET_USERS']),
async deleteUser(data) {
const msg = `Are you sure you want to delete ${data.item.username}?`;
const action = await this.$bvModal.msgBoxConfirm(msg, {});
if (action === true) {
await this.DELETE_USER(data.item.id);
}
},
...mapActions(['GET_USERS', 'DELETE_USER']),
},
computed: {
...mapGetters(['SHOW_USERS', 'CURRENT_SHOW']),
Expand Down
2 changes: 1 addition & 1 deletion server/.pylintrc
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[MASTER]
init-hook="from pylint.config import find_pylintrc; import os, sys; sys.path.append(os.path.dirname(find_pylintrc()))"
ignore-paths=^alembic_config/versions/.*$,
ignore-paths=^alembic_config/versions/.*$,^test/.*$

[MESSAGES CONTROL]
disable=
Expand Down
75 changes: 75 additions & 0 deletions server/alembic_config/versions/29471f7cf7d2_user_deletion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""User deletion

Revision ID: 29471f7cf7d2
Revises: be353176c064
Create Date: 2025-04-23 00:01:32.182458

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "29471f7cf7d2"
down_revision: Union[str, None] = "be353176c064"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("sessions", schema=None) as batch_op:
batch_op.create_foreign_key(
batch_op.f("fk_sessions_user_id_user"),
"user",
["user_id"],
["id"],
ondelete="SET NULL",
)

with op.batch_alter_table("showsession", schema=None) as batch_op:
batch_op.create_foreign_key(
batch_op.f("fk_showsession_user_id_user"),
"user",
["user_id"],
["id"],
ondelete="SET NULL",
)

with op.batch_alter_table("user_settings", schema=None) as batch_op:
batch_op.create_foreign_key(
batch_op.f("fk_user_settings_user_id_user"),
"user",
["user_id"],
["id"],
ondelete="CASCADE",
)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("user_settings", schema=None) as batch_op:
batch_op.drop_constraint(
batch_op.f("fk_user_settings_user_id_user"), type_="foreignkey"
)
batch_op.create_foreign_key(
"fk_user_settings_user_id_user", "user", ["user_id"], ["id"]
)

with op.batch_alter_table("showsession", schema=None) as batch_op:
batch_op.drop_constraint(
batch_op.f("fk_showsession_user_id_user"), type_="foreignkey"
)
batch_op.create_foreign_key(None, "user", ["user_id"], ["id"])

with op.batch_alter_table("sessions", schema=None) as batch_op:
batch_op.drop_constraint(
batch_op.f("fk_sessions_user_id_user"), type_="foreignkey"
)
batch_op.create_foreign_key(None, "user", ["user_id"], ["id"])

# ### end Alembic commands ###
143 changes: 97 additions & 46 deletions server/controllers/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from datetime import datetime

import bcrypt
from tornado import escape, web
from tornado import escape, gen, web
from tornado.ioloop import IOLoop

from models.session import Session
from models.user import User
from registry.named_locks import NamedLockRegistry
from schemas.schemas import UserSchema
from utils.web.base_controller import BaseAPIController
from utils.web.route import ApiRoute, ApiVersion
from utils.web.web_decorators import require_admin, requires_show
from utils.web.web_decorators import no_live_session, require_admin, requires_show


@ApiRoute("auth/create", ApiVersion.V1)
Expand Down Expand Up @@ -69,6 +70,73 @@ async def post(self):
await self.finish({"message": "Successfully created user"})


@ApiRoute("auth/delete", ApiVersion.V1)
class UserDeleteController(BaseAPIController):
@web.authenticated
@require_admin
@no_live_session
async def post(self):
data = escape.json_decode(self.request.body)
user_to_delete = data.get("id", None)
if not user_to_delete:
self.set_status(400)
await self.finish({"message": "Id missing"})
return

with self.make_session() as session:
user_to_delete: User = session.query(User).get(int(user_to_delete))
if not user_to_delete:
self.set_status(400)
await self.finish({"message": "Could not find user to delete"})
return

if user_to_delete.is_admin:
self.set_status(400)
await self.finish({"message": "Cannot delete admin user"})
return

async with NamedLockRegistry.acquire(
f"UserLock::{user_to_delete.username}"
):
# First, log out all sessions for this user
await self.application.ws_send_to_user(
user_to_delete.id, "NOOP", "USER_LOGOUT", {}
)

# Then really make sure we have logged out the user for all sessions (basically,
# wait for the websocket ops to finish)
session_logout_attempts = 0
user_sessions = (
session.query(Session)
.filter(Session.user_id == user_to_delete.id)
.all()
)
while user_sessions and session_logout_attempts < 5:
for user_session in user_sessions:
ws_session = self.application.get_ws(user_session.internal_id)
await ws_session.write_message(
{"OP": "NOOP", "DATA": "{}", "ACTION": "USER_LOGOUT"}
)
await gen.sleep(0.2)
user_sessions = (
session.query(Session)
.filter(Session.user_id == user_to_delete.id)
.all()
)
session_logout_attempts += 1

# Delete all RBAC associations for this user
self.application.rbac.delete_actor(user_to_delete)

# Then we can delete the user
session.delete(user_to_delete)
session.commit()

self.set_status(200)
await self.application.ws_send_to_all("NOOP", "GET_USERS", {})
await self.finish({"message": "Successfully deleted user"})


@ApiRoute("auth/login", ApiVersion.V1)
class LoginHandler(BaseAPIController):
async def post(self):
Expand All @@ -87,34 +155,35 @@ async def post(self):
return

with self.make_session() as session:
user = session.query(User).filter(User.username == username).first()
if not user:
self.set_status(401)
await self.finish({"message": "Invalid username/password"})
return

password_equal = await IOLoop.current().run_in_executor(
None,
bcrypt.checkpw,
escape.utf8(password),
escape.utf8(user.password),
)
async with NamedLockRegistry.acquire(f"UserLock::{username}"):
user = session.query(User).filter(User.username == username).first()
if not user:
self.set_status(401)
await self.finish({"message": "Invalid username/password"})
return

password_equal = await IOLoop.current().run_in_executor(
None,
bcrypt.checkpw,
escape.utf8(password),
escape.utf8(user.password),
)

if password_equal:
session_id = data.get("session_id", "")
if session_id:
ws_session: Session = session.query(Session).get(session_id)
if ws_session:
ws_session.user = user
user.last_login = datetime.utcnow()
session.commit()
if password_equal:
session_id = data.get("session_id", "")
if session_id:
ws_session: Session = session.query(Session).get(session_id)
if ws_session:
ws_session.user = user
user.last_login = datetime.utcnow()
session.commit()

self.set_secure_cookie("digiscript_user_id", str(user.id))
self.set_status(200)
await self.finish({"message": "Successful log in"})
else:
self.set_status(401)
await self.finish({"message": "Invalid username/password"})
self.set_secure_cookie("digiscript_user_id", str(user.id))
self.set_status(200)
await self.finish({"message": "Successful log in"})
else:
self.set_status(401)
await self.finish({"message": "Invalid username/password"})


@ApiRoute("auth/logout", ApiVersion.V1)
Expand All @@ -140,24 +209,6 @@ async def post(self):
await self.finish({"message": "No user logged in"})


@ApiRoute("auth/validate", ApiVersion.V1)
class AuthValidationHandler(BaseAPIController):
@web.authenticated
async def get(self):
if self.current_user["is_admin"]:
self.set_status(200)
await self.finish({"message": "OK"})
elif (
self.current_show
and self.current_user["show_id"] == self.current_show["id"]
):
self.set_status(200)
await self.finish({"message": "OK"})
else:
self.set_status(401)
self.write({"message": "Not Authenticated"})


@ApiRoute("auth/users", ApiVersion.V1)
class UsersHandler(BaseAPIController):
@web.authenticated
Expand Down
1 change: 1 addition & 0 deletions server/digi_server/app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ async def _configure_logging(self):
)

def _configure_rbac(self):
self._db.register_delete_hook(self.rbac.rbac_db.check_object_deletion)
self.rbac.add_mapping(User, Show, [Show.id, Show.name])
self.rbac.add_mapping(User, CueType, [CueType.id, CueType.prefix])
self.rbac.add_mapping(User, Script, [Script.id])
Expand Down
4 changes: 2 additions & 2 deletions server/models/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Session(db.Model):
last_ping = Column(Float())
last_pong = Column(Float())
is_editor = Column(Boolean(), default=False, index=True)
user_id = Column(Integer, ForeignKey("user.id"), index=True)
user_id = Column(Integer, ForeignKey("user.id", ondelete="SET NULL"), index=True)

user = relationship(
"User", uselist=False, backref=backref("sessions", uselist=True)
Expand All @@ -27,7 +27,7 @@ class ShowSession(db.Model):
start_date_time = Column(DateTime())
end_date_time = Column(DateTime())

user_id = Column(Integer, ForeignKey("user.id"), index=True)
user_id = Column(Integer, ForeignKey("user.id", ondelete="SET NULL"), index=True)
client_internal_id = Column(String(255), ForeignKey("sessions.internal_id"))
last_client_internal_id = Column(String(255))
latest_line_ref = Column(String)
Expand Down
2 changes: 1 addition & 1 deletion server/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class UserSettings(db.Model):
__tablename__ = "user_settings"

id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("user.id"), index=True)
user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), index=True)

settings_type = Column(String, index=True)
settings = Column(Text)
Expand Down
Loading
Loading