Initial commit - DesignerCEP Project with Caddy deployment
This commit is contained in:
421
Server/app/services/auth_service.py
Normal file
421
Server/app/services/auth_service.py
Normal file
@@ -0,0 +1,421 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.schemas.auth import UserLogin, UserRegister, Token, VerifyRequest, VerifyResponse
|
||||
from app.schemas.client import LoginData
|
||||
from app.models.user import User
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
from app.models.session import UserSession
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from app.services.email_service import email_service
|
||||
|
||||
from app.models.group import PluginGroup
|
||||
|
||||
class AuthService:
|
||||
def login(self, db: Session, login_data: UserLogin) -> Token:
|
||||
# 根据用户名查找用户并验证密码
|
||||
user = db.query(User).filter(User.username == login_data.username).first()
|
||||
|
||||
if not user or not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 单设备同时在线限制:自动踢掉其他设备的旧会话
|
||||
now = datetime.now(timezone.utc)
|
||||
other_active_sessions = (
|
||||
db.query(UserSession)
|
||||
.filter(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.device_id != login_data.device_id,
|
||||
UserSession.active == True,
|
||||
(UserSession.expires_at == None) | (UserSession.expires_at > now),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 自动踢掉其他设备(设置为非活跃)
|
||||
if other_active_sessions:
|
||||
for session in other_active_sessions:
|
||||
session.active = False
|
||||
session.logout_at = now
|
||||
# 计算该会话的时长
|
||||
if session.login_at:
|
||||
login_at = session.login_at
|
||||
if login_at.tzinfo is None:
|
||||
login_at = login_at.replace(tzinfo=timezone.utc)
|
||||
session.duration_seconds = int((now - login_at).total_seconds())
|
||||
db.commit()
|
||||
|
||||
token = create_access_token(subject=login_data.username, device_id=login_data.device_id)
|
||||
# 记录/更新当前设备会话
|
||||
|
||||
session = (
|
||||
db.query(UserSession)
|
||||
.filter(UserSession.user_id == user.id, UserSession.device_id == login_data.device_id)
|
||||
.first()
|
||||
)
|
||||
expires = now + timedelta(days=7) # 7 天有效期
|
||||
|
||||
if session:
|
||||
session.active = True
|
||||
session.expires_at = expires
|
||||
session.login_at = now
|
||||
session.logout_at = None
|
||||
session.duration_seconds = None
|
||||
session.last_seen_at = now
|
||||
else:
|
||||
session = UserSession(
|
||||
user_id=user.id,
|
||||
device_id=login_data.device_id,
|
||||
active=True,
|
||||
expires_at=expires,
|
||||
login_at=now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(session)
|
||||
|
||||
db.commit()
|
||||
|
||||
return Token(access_token=token, token_type="bearer", username=login_data.username)
|
||||
|
||||
def client_login(self, db: Session, login_data: UserLogin) -> LoginData:
|
||||
# Re-use logic or call login internally?
|
||||
# Ideally refactor, but for now let's copy the essential verification logic to ensure correct return type
|
||||
# Or better, call login to get token and session handling, then enrich data.
|
||||
|
||||
token_obj = self.login(db, login_data)
|
||||
user = db.query(User).filter(User.username == login_data.username).first()
|
||||
|
||||
permissions_list = []
|
||||
if user.permissions:
|
||||
permissions_list = [p.strip() for p in user.permissions.split(",")]
|
||||
|
||||
expire_date_str = None
|
||||
if user.expire_date:
|
||||
expire_date_str = user.expire_date.strftime("%Y-%m-%d")
|
||||
|
||||
return LoginData(
|
||||
token=token_obj.access_token,
|
||||
username=user.username,
|
||||
expire_date=expire_date_str,
|
||||
permissions=permissions_list
|
||||
)
|
||||
|
||||
def register(self, db: Session, register_data: UserRegister) -> Token:
|
||||
# 校验确认密码一致性
|
||||
if register_data.password != register_data.confirm_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="两次输入的密码不一致"
|
||||
)
|
||||
# 检查用户名是否已存在
|
||||
existing = db.query(User).filter(User.username == register_data.username).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户名已被注册"
|
||||
)
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
if register_data.email:
|
||||
existing_email = db.query(User).filter(User.email == register_data.email).first()
|
||||
if existing_email:
|
||||
# 如果已验证,或者是旧流程(无验证码),则报错
|
||||
# 新流程(有验证码)允许存在未验证的用户记录(即临时用户)
|
||||
is_new_flow = hasattr(register_data, "code") and register_data.code
|
||||
if existing_email.is_verified or not is_new_flow:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册"
|
||||
)
|
||||
|
||||
# 验证码校验逻辑 (如果提供了验证码)
|
||||
if hasattr(register_data, "code") and register_data.code:
|
||||
# 这里需要一种机制来验证验证码,通常需要先调用 send-verification-code 接口
|
||||
# 并在数据库或缓存中暂存验证码。
|
||||
# 由于当前 User 表是注册成功才创建,我们需要一个临时存储或者允许预先创建未验证用户。
|
||||
# 方案:先查询是否有未验证的同名/同邮箱用户,或者使用 Redis。
|
||||
# 简单方案:使用一个专门的 VerificationCode 表,或者复用 User 表但标记状态。
|
||||
|
||||
# 这里为了配合新的需求,我们需要修改 User 表的使用方式:
|
||||
# 1. 发送验证码时,如果用户不存在,创建一个 is_verified=False 的用户,存 code
|
||||
# 2. 注册时,查找该用户,验证 code,更新密码等信息,设 is_verified=True
|
||||
|
||||
# 但 send-verification-code 接口目前还未实现,我们先假设用户通过该接口发送了验证码
|
||||
# 并且我们通过 email 查找到了这个预创建的用户记录
|
||||
|
||||
pre_user = db.query(User).filter(User.email == register_data.email).first()
|
||||
if not pre_user:
|
||||
raise HTTPException(status_code=400, detail="请先发送验证码")
|
||||
if pre_user.verification_code != register_data.code:
|
||||
raise HTTPException(status_code=400, detail="验证码错误")
|
||||
|
||||
# 验证通过,更新用户信息 (从预创建转为正式)
|
||||
user = pre_user
|
||||
user.username = register_data.username
|
||||
user.hashed_password = get_password_hash(register_data.password)
|
||||
user.is_verified = True
|
||||
user.verification_code = None
|
||||
|
||||
# 处理组逻辑
|
||||
target_group = db.query(PluginGroup).filter(PluginGroup.name == "default").first()
|
||||
if not target_group:
|
||||
target_group = db.query(PluginGroup).first()
|
||||
user.group_id = target_group.id if target_group else None
|
||||
|
||||
else:
|
||||
# 旧逻辑:直接创建,后续验证
|
||||
# 创建新用户,保存哈希后的密码
|
||||
# 自动分配组策略:
|
||||
# 1. 尝试查找名为 "default" 的组
|
||||
# 2. 如果不存在,尝试使用数据库中第一个组
|
||||
# 3. 如果没有任何组,则 group_id 为 None
|
||||
|
||||
target_group = db.query(PluginGroup).filter(PluginGroup.name == "default").first()
|
||||
if not target_group:
|
||||
target_group = db.query(PluginGroup).first()
|
||||
|
||||
group_id = target_group.id if target_group else None
|
||||
|
||||
user = User(
|
||||
username=register_data.username,
|
||||
hashed_password=get_password_hash(register_data.password),
|
||||
group_id=group_id,
|
||||
email=register_data.email,
|
||||
is_verified=False
|
||||
)
|
||||
|
||||
# 如果提供了邮箱,生成验证码并发送
|
||||
if register_data.email:
|
||||
code = ''.join(random.choices(string.digits, k=6))
|
||||
user.verification_code = code
|
||||
try:
|
||||
email_service.send_verification_email(register_data.email, code)
|
||||
except Exception as e:
|
||||
print(f"Failed to send verification email: {e}")
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# 注册成功后,自动创建会话并登录
|
||||
# 注意:这里需要 device_id,如果前端未传,默认值为 "unknown_device"
|
||||
device_id = getattr(register_data, "device_id", "unknown_device")
|
||||
|
||||
# 创建 Session
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = now + timedelta(days=7) # 保持与 Login 一致
|
||||
session = UserSession(
|
||||
user_id=user.id,
|
||||
device_id=device_id,
|
||||
active=True,
|
||||
expires_at=expires,
|
||||
login_at=now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
token = create_access_token(subject=register_data.username, device_id=device_id)
|
||||
return Token(access_token=token, token_type="bearer", username=register_data.username)
|
||||
|
||||
def send_verification_code(self, db: Session, email: str) -> dict:
|
||||
# 1. 检查邮箱是否已被正式注册
|
||||
existing = db.query(User).filter(User.email == email, User.is_verified == True).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="该邮箱已被注册")
|
||||
|
||||
# 2. 查找或创建临时用户记录
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
code = ''.join(random.choices(string.digits, k=6))
|
||||
|
||||
if user:
|
||||
# 更新现有临时用户的验证码
|
||||
user.verification_code = code
|
||||
else:
|
||||
# 创建临时用户 (username 暂时用 email 占位,注册时会更新)
|
||||
# 注意:username 是 unique 且 nullable=False,所以必须给一个值
|
||||
# 我们可以用 "temp_{email}" 或者随机字符串,只要不冲突
|
||||
temp_username = f"temp_{secrets.token_hex(8)}"
|
||||
user = User(
|
||||
username=temp_username,
|
||||
email=email,
|
||||
hashed_password="temp_password_placeholder", # 必填字段占位
|
||||
is_verified=False,
|
||||
verification_code=code
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 3. 发送邮件
|
||||
try:
|
||||
email_service.send_verification_email(email, code)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}")
|
||||
|
||||
return {"detail": "验证码已发送"}
|
||||
|
||||
def verify_email(self, db: Session, username: str, code: str) -> dict:
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user.is_verified:
|
||||
return {"detail": "邮箱已验证"}
|
||||
if not user.verification_code or user.verification_code != code:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误")
|
||||
|
||||
user.is_verified = True
|
||||
user.verification_code = None
|
||||
db.commit()
|
||||
return {"detail": "验证成功"}
|
||||
|
||||
def forgot_password(self, db: Session, email: str) -> dict:
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
# 为安全起见,不提示用户不存在,或者提示已发送
|
||||
return {"detail": "如果邮箱存在,重置邮件已发送"}
|
||||
|
||||
# 改用6位数字验证码作为Token(为了用户体验)
|
||||
token = ''.join(random.choices(string.digits, k=6))
|
||||
|
||||
# 存储 Token (复用 reset_token 字段,虽然叫 token 但存的是验证码)
|
||||
user.reset_token = token
|
||||
user.reset_token_expire = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
email_service.send_reset_password_email(email, token)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}")
|
||||
|
||||
return {"detail": "如果邮箱存在,重置邮件已发送"}
|
||||
|
||||
def reset_password(self, db: Session, token: str, new_password: str, email: str) -> dict:
|
||||
# 修改:reset_password 需要 email + code 来定位用户
|
||||
# 因为6位验证码可能重复,所以必须配合邮箱查找
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
if user.reset_token != token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误")
|
||||
|
||||
if not user.reset_token_expire:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码无效")
|
||||
|
||||
expire_time = user.reset_token_expire
|
||||
if expire_time.tzinfo is None:
|
||||
expire_time = expire_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expire_time < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期")
|
||||
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
user.reset_token = None
|
||||
user.reset_token_expire = None
|
||||
db.commit()
|
||||
return {"detail": "密码重置成功"}
|
||||
|
||||
def logout(self, db: Session, username: str, device_id: str) -> dict:
|
||||
# 将指定用户的指定设备会话标记为非活跃
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
session = (
|
||||
db.query(UserSession)
|
||||
.filter(UserSession.user_id == user.id, UserSession.device_id == device_id, UserSession.active == True)
|
||||
.first()
|
||||
)
|
||||
if not session:
|
||||
# 没有活跃会话也视为成功,前端可以重入
|
||||
return {"detail": "已退出登录"}
|
||||
session.active = False
|
||||
# 记录退出时间与在线时长(秒)
|
||||
now = datetime.now(timezone.utc)
|
||||
session.logout_at = now
|
||||
if session.login_at:
|
||||
login_at = session.login_at
|
||||
if login_at.tzinfo is None:
|
||||
login_at = login_at.replace(tzinfo=timezone.utc)
|
||||
session.duration_seconds = int((now - login_at).total_seconds())
|
||||
db.commit()
|
||||
return {"detail": "已退出登录"}
|
||||
|
||||
def heartbeat(self, db: Session, username: str, device_id: str) -> dict:
|
||||
# 更新指定设备会话的最近心跳时间,用于统计活跃在线时长
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
session = (
|
||||
db.query(UserSession)
|
||||
.filter(UserSession.user_id == user.id, UserSession.device_id == device_id, UserSession.active == True)
|
||||
.first()
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在或已登出")
|
||||
session.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"detail": "心跳已更新"}
|
||||
|
||||
def verify_license(self, db: Session, verify_data: VerifyRequest, current_username: str) -> VerifyResponse:
|
||||
# 1. 验证用户是否存在
|
||||
user = db.query(User).filter(User.username == verify_data.username).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 2. 检查 Token 用户一致性
|
||||
if current_username != verify_data.username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Token 用户与请求用户不匹配"
|
||||
)
|
||||
|
||||
# 3. 检查账户是否过期
|
||||
expire_date_str = None
|
||||
if user.expire_date:
|
||||
expire_dt = user.expire_date
|
||||
if expire_dt.tzinfo is None:
|
||||
expire_dt = expire_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
if datetime.now(timezone.utc) > expire_dt:
|
||||
return VerifyResponse(
|
||||
valid=False,
|
||||
username=user.username,
|
||||
expire_date=user.expire_date.isoformat()
|
||||
)
|
||||
expire_date_str = user.expire_date.isoformat()
|
||||
|
||||
# 4. 检查会话是否活跃
|
||||
session = db.query(UserSession).filter(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.device_id == verify_data.device_id,
|
||||
UserSession.active == True
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="会话不存在或已登出"
|
||||
)
|
||||
|
||||
# 5. 更新最后活跃时间
|
||||
session.last_seen_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
return VerifyResponse(
|
||||
valid=True,
|
||||
username=user.username,
|
||||
expire_date=expire_date_str
|
||||
)
|
||||
|
||||
auth_service = AuthService()
|
||||
63
Server/app/services/email_service.py
Normal file
63
Server/app/services/email_service.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EmailService:
|
||||
def send_email(self, to_email: str, subject: str, body: str):
|
||||
if not settings.SMTP_USER or settings.SMTP_USER == "your-email@gmail.com":
|
||||
logger.warning("SMTP not configured, skipping email sending.")
|
||||
print(f"--- Mock Email ---\nTo: {to_email}\nSubject: {subject}\nBody: {body}\n------------------")
|
||||
return
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>"
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = subject
|
||||
|
||||
msg.attach(MIMEText(body, 'html'))
|
||||
|
||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
server.starttls()
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
text = msg.as_string()
|
||||
server.sendmail(settings.EMAILS_FROM_EMAIL, to_email, text)
|
||||
server.quit()
|
||||
logger.info(f"Email sent to {to_email}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
raise e
|
||||
|
||||
def send_verification_email(self, to_email: str, code: str):
|
||||
subject = "验证您的邮箱 - Designer"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>欢迎注册 Designer</h2>
|
||||
<p>您的验证码是:<strong style="font-size: 24px; color: #165DFF;">{code}</strong></p>
|
||||
<p>请输入此验证码完成注册。如果您没有请求此代码,请忽略此邮件。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.send_email(to_email, subject, body)
|
||||
|
||||
def send_reset_password_email(self, to_email: str, token: str):
|
||||
subject = "重置密码 - Designer"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>重置密码</h2>
|
||||
<p>您收到了这封邮件是因为您(或者其他人)请求重置您的账户密码。</p>
|
||||
<p>您的重置验证码是:<strong style="font-size: 24px; color: #165DFF;">{token}</strong></p>
|
||||
<p>请在重置密码页面输入此验证码。</p>
|
||||
<p>如果您没有请求重置密码,请忽略此邮件。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.send_email(to_email, subject, body)
|
||||
|
||||
email_service = EmailService()
|
||||
60
Server/app/services/group_service.py
Normal file
60
Server/app/services/group_service.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.user import User
|
||||
from app.models.group import PluginGroup
|
||||
from app.schemas.client import CheckUpdateData
|
||||
from datetime import datetime, timezone
|
||||
|
||||
class GroupService:
|
||||
def check_update(self, db: Session, username: str) -> CheckUpdateData:
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
# Check expiration
|
||||
is_expired = False
|
||||
if user.expire_date:
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_date = user.expire_date
|
||||
if expire_date.tzinfo is None:
|
||||
expire_date = expire_date.replace(tzinfo=timezone.utc)
|
||||
if now > expire_date:
|
||||
is_expired = True
|
||||
|
||||
# Get group and version
|
||||
if not user.group_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户未分配组")
|
||||
|
||||
group = db.query(PluginGroup).filter(PluginGroup.id == user.group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户所属组不存在")
|
||||
|
||||
if not group.current_version_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前组无可用版本")
|
||||
|
||||
# Construct download URL (assuming base URL or relative path)
|
||||
# In a real scenario, this might be from config
|
||||
download_url = f"/download/{group.current_version_file}"
|
||||
|
||||
# Extract version from filename roughly or use file metadata if available
|
||||
# Assuming filename format: plugin_v1.0.2.zip
|
||||
version = "unknown"
|
||||
filename = group.current_version_file
|
||||
if "v" in filename:
|
||||
try:
|
||||
# Simple extraction logic, can be improved
|
||||
parts = filename.split("v")
|
||||
if len(parts) > 1:
|
||||
version_part = parts[1]
|
||||
version = "v" + version_part.split(".zip")[0].split("_")[0]
|
||||
except:
|
||||
pass
|
||||
|
||||
return CheckUpdateData(
|
||||
version=version,
|
||||
download_url=download_url,
|
||||
force_update=False,
|
||||
is_expired=is_expired
|
||||
)
|
||||
|
||||
group_service = GroupService()
|
||||
Reference in New Issue
Block a user