feat: add authentication module and update UI

This commit is contained in:
ihmily
2025-06-06 17:18:54 +08:00
parent 5ab992ab9f
commit e8f5d6ce91
9 changed files with 499 additions and 25 deletions

View File

@@ -28,6 +28,9 @@ class App:
self.assets_dir = os.path.join(execute_dir, "assets")
self.process_manager = AsyncProcessManager()
self.config_manager = ConfigManager(self.run_path)
self.is_web_mode = False
self.auth_manager = None
self.current_username = None
self.content_area = ft.Column(
controls=[],
expand=True,
@@ -56,7 +59,6 @@ class App:
self.page.snack_bar_area,
]
)
self.page.add(self.complete_page)
self.snack_bar = ShowSnackBar(self.page)
self.subprocess_start_up_info = utils.get_startup_info()
self.record_card_manager = RecordingCardManager(self)

0
app/auth/__init__.py Normal file
View File

93
app/auth/auth_manager.py Normal file
View File

@@ -0,0 +1,93 @@
import hashlib
import secrets
from typing import Optional
from ..utils.logger import logger
class AuthManager:
def __init__(self, app):
self.app = app
self.config_manager = app.config_manager
self.is_authenticated = False
self.session_token = None
self.active_sessions = {}
async def initialize(self):
web_auth = self.config_manager.load_web_auth_config()
if not web_auth.get("users"):
default_username = "admin"
default_password = "admin"
salt = secrets.token_hex(8)
hashed_password = self._hash_password(default_password, salt)
web_auth["users"] = [{
"username": default_username,
"password_hash": hashed_password,
"salt": salt,
"is_admin": True
}]
await self.config_manager.save_web_auth_config(web_auth)
logger.info("Default account created: admin/admin")
def _hash_password(self, password: str, salt: str) -> str:
"""Use SHA-256 and salt to hash password"""
return hashlib.sha256((password + salt).encode()).hexdigest()
def _generate_session_token(self) -> str:
"""Generate session token"""
return secrets.token_hex(16)
async def authenticate(self, username: str, password: str) -> tuple[bool, Optional[str]]:
web_auth = self.config_manager.load_web_auth_config()
users = web_auth.get("users", [])
for user in users:
if user["username"] == username:
salt = user["salt"]
hashed_password = self._hash_password(password, salt)
if hashed_password == user["password_hash"]:
session_token = self._generate_session_token()
self.active_sessions[session_token] = {
"username": username,
"is_admin": user.get("is_admin", False)
}
return True, session_token
return False, None
def validate_session(self, session_token: str) -> bool:
"""Validate session token"""
return session_token in self.active_sessions
def logout(self, session_token: str) -> bool:
if session_token in self.active_sessions:
del self.active_sessions[session_token]
return True
return False
async def change_password(self, username: str, old_password: str, new_password: str) -> bool:
web_auth = self.config_manager.load_web_auth_config()
users = web_auth.get("users", [])
for i, user in enumerate(users):
if user["username"] == username:
salt = user["salt"]
hashed_old_password = self._hash_password(old_password, salt)
if hashed_old_password == user["password_hash"]:
new_salt = secrets.token_hex(8)
hashed_new_password = self._hash_password(new_password, new_salt)
web_auth["users"][i]["password_hash"] = hashed_new_password
web_auth["users"][i]["salt"] = new_salt
await self.config_manager.save_web_auth_config(web_auth)
return True
return False

View File

@@ -18,6 +18,7 @@ class ConfigManager:
self.about_config_path = os.path.join(self.config_path, "version.json")
self.recordings_config_path = os.path.join(self.config_path, "recordings.json")
self.accounts_config_path = os.path.join(self.config_path, "accounts.json")
self.web_auth_config_path = os.path.join(self.config_path, "web_auth.json")
os.makedirs(os.path.dirname(self.default_config_path), exist_ok=True)
self.init()
@@ -28,6 +29,7 @@ class ConfigManager:
self.init_cookies_config()
self.init_accounts_config()
self.init_recordings_config()
self.init_web_auth_config()
@staticmethod
def _init_config(config_path, default_config=None):
@@ -63,6 +65,10 @@ class ConfigManager:
cookies_config = {}
self._init_config(self.recordings_config_path, cookies_config)
def init_web_auth_config(self):
cookies_config = {}
self._init_config(self.web_auth_config_path, cookies_config)
@staticmethod
def _load_config(config_path, error_message):
"""Load configuration from a JSON file."""
@@ -104,6 +110,9 @@ class ConfigManager:
"""Load i18n configuration from a JSON file."""
return self._load_config(path, "An error occurred while loading i18n config")
def load_web_auth_config(self):
return self._load_config(self.web_auth_config_path, "An error occurred while loading web auth config")
@staticmethod
async def _save_config(config_path, config, success_message, error_message):
"""Save configuration to a JSON file."""
@@ -130,6 +139,14 @@ class ConfigManager:
error_message="An error occurred while saving accounts config",
)
async def save_web_auth_config(self, config):
await self._save_config(
self.web_auth_config_path,
config,
success_message="Web auth configuration saved.",
error_message="An error occurred while saving web auth config",
)
async def save_user_config(self, config):
await self._save_config(
self.user_config_path,

180
app/ui/views/login_view.py Normal file
View File

@@ -0,0 +1,180 @@
from typing import Callable
import flet as ft
from ...auth.auth_manager import AuthManager
from ...utils.logger import logger
class LoginPage:
def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: Callable):
self.page = page
self.auth_manager = auth_manager
self.on_login_success = on_login_success
app = auth_manager.app
language = app.language_manager.language
self._ = language.get("login_page", {})
self.page.title = self._["login_title"]
self.username_field = ft.TextField(
label=self._["username"],
autofocus=True,
width=320,
border_radius=8,
prefix_icon=ft.icons.PERSON,
focused_border_color="#0078d4",
focused_color="#0078d4",
border_color="#d0d0d0",
bgcolor="#f5f5f5",
color="#333333",
label_style=ft.TextStyle(color="#666666"),
)
self.password_field = ft.TextField(
label=self._["password"],
password=True,
can_reveal_password=True,
width=320,
border_radius=8,
prefix_icon=ft.icons.LOCK_OUTLINE,
focused_border_color="#0078d4",
focused_color="#0078d4",
border_color="#d0d0d0",
bgcolor="#f5f5f5",
color="#333333",
label_style=ft.TextStyle(color="#666666"),
)
self.login_button = ft.ElevatedButton(
text=self._["login_button"],
width=320,
on_click=self.handle_login,
style=ft.ButtonStyle(
shape=ft.RoundedRectangleBorder(radius=8),
color="#ffffff",
bgcolor="#0078d4",
elevation=0,
padding=15,
animation_duration=300,
),
)
self.error_text = ft.Text(
color=ft.colors.RED_500,
size=14,
visible=False,
)
self.logo = ft.Image(
src="/icons/loading-animation.png",
width=80,
height=80,
fit=ft.ImageFit.CONTAIN,
)
login_card_content = ft.Column(
controls=[
ft.Container(
content=self.logo,
alignment=ft.alignment.center,
margin=ft.margin.only(bottom=10),
),
ft.Text(
"StreamCap",
size=28,
weight=ft.FontWeight.BOLD,
color="#0078d4",
text_align=ft.TextAlign.CENTER,
),
ft.Text(
self._["login_subtitle"],
size=16,
color="#666666",
text_align=ft.TextAlign.CENTER,
),
ft.Container(height=20),
self.username_field,
ft.Container(height=10),
self.password_field,
ft.Container(
content=self.error_text,
margin=ft.margin.only(top=10),
alignment=ft.alignment.center,
),
ft.Container(height=20),
self.login_button,
ft.Container(height=10),
ft.Text(
self._["default_account_tip"],
size=12,
color="#999999",
text_align=ft.TextAlign.CENTER,
),
],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=5,
)
self.login_card = ft.Container(
content=login_card_content,
width=400,
height=600,
padding=30,
bgcolor=ft.colors.WHITE,
border_radius=12,
shadow=ft.BoxShadow(
spread_radius=1,
blur_radius=15,
color=ft.colors.with_opacity(0.1, "#000000"),
offset=ft.Offset(0, 4),
),
)
self.main_view = ft.Container(
content=self.login_card,
alignment=ft.alignment.center,
expand=True,
bgcolor="#f0f2f5",
)
async def handle_login(self, e):
username = self.username_field.value
password = self.password_field.value
if not username or not password:
self.show_error(self._["input_required"])
return
original_text = self.login_button.text
self.login_button.text = self._["login_in_progress"]
self.login_button.disabled = True
self.page.update()
success, token = await self.auth_manager.authenticate(username, password)
self.login_button.text = original_text
self.login_button.disabled = False
self.page.update()
if success:
logger.info(f"Login successful: {username}")
await self.page.client_storage.set_async("session_token", token)
await self.on_login_success(token)
else:
self.show_error(self._["login_failed"])
def show_error(self, message: str):
self.error_text.value = message
self.error_text.visible = True
self.page.update()
def clear_error(self):
self.error_text.visible = False
self.page.update()
def get_view(self) -> ft.Control:
return self.main_view

View File

@@ -30,6 +30,7 @@ class SettingsPage(PageBase):
self.tab_push = None
self.tab_cookies = None
self.tab_accounts = None
self.tab_security = None
self.has_unsaved_changes = {}
self.delay_handler = DelayedTaskExecutor(self.app, self)
self.load_language()
@@ -37,8 +38,6 @@ class SettingsPage(PageBase):
self.page.on_keyboard_event = self.on_keyboard
async def load(self):
"""Load the settings page content with tabs for different categories."""
self.content_area.clean()
language = self.app.language_manager.language
self._ = language["settings_page"] | language["video_quality"] | language["base"]
@@ -48,15 +47,21 @@ class SettingsPage(PageBase):
self.tab_accounts = self.create_accounts_settings_tab()
self.page.on_keyboard_event = self.on_keyboard
tabs = [
ft.Tab(text=self._["recording_settings"], content=self.tab_recording),
ft.Tab(text=self._["push_settings"], content=self.tab_push),
ft.Tab(text=self._["cookies_settings"], content=self.tab_cookies),
ft.Tab(text=self._["accounts_settings"], content=self.tab_accounts),
]
if self.app.page.web:
self.tab_security = self.create_security_settings_tab()
tabs.append(ft.Tab(text=self._["security_settings"], content=self.tab_security))
settings_tabs = ft.Tabs(
selected_index=0,
animation_duration=300,
tabs=[
ft.Tab(text=self._["recording_settings"], content=self.tab_recording),
ft.Tab(text=self._["push_settings"], content=self.tab_push),
ft.Tab(text=self._["cookies_settings"], content=self.tab_cookies),
ft.Tab(text=self._["accounts_settings"], content=self.tab_accounts),
],
tabs=tabs,
)
scrollable_content = ft.Container(
@@ -143,12 +148,12 @@ class SettingsPage(PageBase):
self.user_config[key] = e.data.lower() == "true"
else:
self.user_config[key] = e.data
if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]:
for recording in self.app.record_manager.recordings:
recording.recording_dir = None
self.page.run_task(self.app.record_manager.persist_recordings)
if key == "language":
self.load_language()
self.app.language_manager.load()
@@ -759,7 +764,7 @@ class SettingsPage(PageBase):
self._["telegram"], ft.Icons.SMS, "telegram_enabled"
),
]
if self.app.page.web:
return ft.Row(
controls=controls,
@@ -778,7 +783,7 @@ class SettingsPage(PageBase):
),
expand=True,
)
def create_cookies_settings_tab(self):
"""Create UI elements for push configuration."""
platforms = [
@@ -848,7 +853,7 @@ class SettingsPage(PageBase):
)
def create_accounts_settings_tab(self):
"""Create UI elements for push configuration."""
"""Create UI elements for platform accounts configuration."""
return ft.Column(
[
self.create_setting_group(
@@ -946,7 +951,6 @@ class SettingsPage(PageBase):
)
def create_folder_setting_row(self, label):
"""Helper method to create a row of checkboxes for folder settings."""
return ft.Row(
[
ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT),
@@ -1030,7 +1034,8 @@ class SettingsPage(PageBase):
def create_setting_row(self, label, control):
"""Helper method to create a row for each setting."""
control.on_focus = lambda e: self.set_focused_control(e.control)
if hasattr(control, 'on_focus'):
control.on_focus = lambda e: self.set_focused_control(e.control)
return ft.Row(
[ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control],
alignment=ft.MainAxisAlignment.START,
@@ -1095,4 +1100,100 @@ class SettingsPage(PageBase):
self.app.dialog_area.update()
if self.app.current_page == self and e.ctrl and e.key == "S":
self.page.run_task(self.is_changed)
self.page.run_task(self.is_changed)
def create_security_settings_tab(self):
async def change_password(_):
old_password = old_password_field.value
new_password = new_password_field.value
confirm_password = confirm_password_field.value
if not old_password:
await self.app.snack_bar.show_snack_bar(self._["old_password_required"], bgcolor=ft.Colors.RED)
return
if not new_password:
await self.app.snack_bar.show_snack_bar(self._["new_password_required"], bgcolor=ft.Colors.RED)
return
if new_password != confirm_password:
await self.app.snack_bar.show_snack_bar(self._["passwords_not_match"], bgcolor=ft.Colors.RED)
return
_username = self.app.current_username
if _username:
success = await self.app.auth_manager.change_password(_username, old_password, new_password)
if success:
old_password_field.value = ""
new_password_field.value = ""
confirm_password_field.value = ""
old_password_field.update()
new_password_field.update()
confirm_password_field.update()
await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.GREEN)
else:
await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED)
else:
await self.app.snack_bar.show_snack_bar(self._["not_logged_in"], bgcolor=ft.Colors.RED)
username = self.app.current_username or "admin"
old_password_field = ft.TextField(
password=True,
width=300,
label=self._["old_password"],
)
new_password_field = ft.TextField(
password=True,
width=300,
label=self._["new_password"],
)
confirm_password_field = ft.TextField(
password=True,
width=300,
label=self._["confirm_password"],
)
change_password_button = ft.ElevatedButton(
text=self._["change_password"],
on_click=change_password,
icon=ft.icons.LOCK_RESET,
)
return ft.Column(
[
self.create_setting_group(
self._["security_settings"],
self._["web_login_configuration"],
[
self.create_setting_row(
self._["current_username"],
ft.Text(username),
),
self.create_setting_row(
self._["old_password"],
old_password_field,
),
self.create_setting_row(
self._["new_password"],
new_password_field,
),
self.create_setting_row(
self._["confirm_password"],
confirm_password_field,
),
self.create_setting_row(
"",
change_password_button,
),
],
),
],
spacing=10,
scroll=ft.ScrollMode.AUTO,
)

View File

@@ -144,6 +144,7 @@
"basic_settings": "Basic Settings",
"cookies_settings": "Cookie Settings",
"accounts_settings": "Account Settings",
"security_settings": "Security Settings",
"select": "select",
"unsupported_select_path": "Path selection is not supported on the web 📂 Please enter manually",
"program_config": "Basic configuration of the program",
@@ -178,6 +179,18 @@
"custom_script": "Execute Custom Script After Recording",
"script_command": "Custom Script Execution Command",
"default_platform_with_proxy": "Default Platform for Recording with Proxy",
"web_login_configuration": "Web Backend Login Configuration",
"current_username": "Current Username",
"old_password": "Old Password",
"new_password": "New Password",
"confirm_password": "Confirm Password",
"change_password": "Change Password",
"old_password_required": "Old password cannot be empty",
"new_password_required": "New password cannot be empty",
"passwords_not_match": "The two passwords do not match",
"password_changed": "Password changed successfully",
"old_password_incorrect": "Old password is incorrect",
"not_logged_in": "You are not logged in",
"push_notifications": "Push Notifications",
"stream_start_notification_enabled": "Live Status Notification",
"open_broadcast_push_enabled": "Broadcast Start Push",
@@ -402,5 +415,17 @@
"saving_recordings": "Saving {active_recordings_count} recordings, please wait...",
"confirm_exit": "Confirm Exit",
"confirm_exit_content": "Are you sure you want to exit the program?"
},
"login_page": {
"login_title": "StreamCap Login",
"login_subtitle": "Please login to your account",
"username": "Username",
"password": "Password",
"login_button": "Login",
"login_in_progress": "Logging in...",
"default_account_tip": "Default account: admin / Password: admin",
"input_required": "Please enter username and password",
"login_failed": "Username or password is incorrect",
"login_success": "Login successful"
}
}

View File

@@ -145,6 +145,7 @@
"basic_settings": "基础设置",
"cookies_settings": "Cookie设置",
"accounts_settings": "账号设置",
"security_settings": "安全设置",
"select": "选择",
"unsupported_select_path": "Web端不支持选择路径📂请手动输入",
"program_config": "程序的基本设置",
@@ -179,6 +180,18 @@
"custom_script": "录制完成后执行自定义脚本",
"script_command": "自定义脚本执行命令",
"default_platform_with_proxy": "默认使用代理录制的平台",
"web_login_configuration": "Web后台登录配置",
"current_username": "当前用户名",
"old_password": "旧密码",
"new_password": "新密码",
"confirm_password": "确认密码",
"change_password": "修改密码",
"old_password_required": "旧密码不能为空",
"new_password_required": "新密码不能为空",
"passwords_not_match": "两次输入的密码不一致",
"password_changed": "密码修改成功",
"old_password_incorrect": "旧密码不正确",
"not_logged_in": "您尚未登录",
"push_notifications": "推送通知",
"stream_start_notification_enabled": "直播状态推送开关",
"open_broadcast_push_enabled": "开播推送开启",
@@ -403,5 +416,17 @@
"saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...",
"confirm_exit": "确认退出",
"confirm_exit_content": "确定要退出程序吗?"
},
"login_page": {
"login_title": "StreamCap 登录",
"login_subtitle": "请登录您的账号",
"username": "用户名",
"password": "密码",
"login_button": "登录",
"login_in_progress": "登录中...",
"default_account_tip": "默认账号: admin / 密码: admin",
"input_required": "请输入用户名和密码",
"login_failed": "用户名或密码错误",
"login_success": "登录成功"
}
}

47
main.py
View File

@@ -7,8 +7,10 @@ from dotenv import load_dotenv
from screeninfo import get_monitors
from app.app_manager import App, execute_dir
from app.auth.auth_manager import AuthManager
from app.lifecycle.app_close_handler import handle_app_close
from app.ui.components.save_progress_overlay import SaveProgressOverlay
from app.ui.views.login_view import LoginPage
from app.utils.logger import logger
DEFAULT_HOST = "127.0.0.1"
@@ -78,7 +80,7 @@ def handle_disconnect(page: ft.Page) -> callable:
return disconnect
def main(page: ft.Page) -> None:
async def main(page: ft.Page) -> None:
page.title = "StreamCap"
page.window.min_width = MIN_WIDTH
@@ -89,6 +91,7 @@ def main(page: ft.Page) -> None:
app = App(page)
page.data = app
app.is_web_mode = is_web
theme_mode = app.settings.user_config.get("theme_mode", "light")
if theme_mode == "dark":
@@ -98,16 +101,44 @@ def main(page: ft.Page) -> None:
save_progress_overlay = SaveProgressOverlay(app)
page.overlay.append(save_progress_overlay.overlay)
async def load_app():
page.add(app.complete_page)
page.on_route_change = handle_route_change(page, app)
page.window.prevent_close = True
page.window.on_event = handle_window_event(page, app, save_progress_overlay)
page.on_route_change = handle_route_change(page, app)
page.window.prevent_close = True
page.window.on_event = handle_window_event(page, app, save_progress_overlay)
if is_web:
page.on_disconnect = handle_disconnect(page)
page.update()
page.on_route_change(ft.RouteChangeEvent(route=page.route))
if is_web:
page.on_disconnect = handle_disconnect(page)
page.update()
page.on_route_change(ft.RouteChangeEvent(route=page.route))
auth_manager = AuthManager(app)
app.auth_manager = auth_manager
await auth_manager.initialize()
session_token = await page.client_storage.get_async("session_token")
if not session_token or not auth_manager.validate_session(session_token):
async def on_login_success(token):
_session_info = auth_manager.active_sessions.get(token, {})
app.current_username = _session_info.get("username")
page.clean()
await load_app()
page.clean()
login_page = LoginPage(page, auth_manager, on_login_success)
page.add(login_page.get_view())
return
else:
session_info = auth_manager.active_sessions.get(session_token, {})
app.current_username = session_info.get("username")
await load_app()
if __name__ == "__main__":