diff --git a/server/model/schema.py b/server/model/schema.py index 6007ff89..cbbbd4e4 100644 --- a/server/model/schema.py +++ b/server/model/schema.py @@ -129,6 +129,13 @@ class BindUser(Base): email = db.Column(db.String(128), nullable=True, comment="邮箱") name = db.Column(db.String(128), nullable=True, comment="用户名") avatar = db.Column(db.String(256), nullable=True, comment="头像") + access_token = db.Column( + db.String(128), nullable=True, comment="GitHub access_token" + ) + refresh_token = db.Column( + db.String(128), nullable=True, comment="GitHub refresh_token" + ) + expire_time = db.Column(db.Integer, nullable=True, comment="GitHub token过期时间 时间戳") extra = db.Column( JSONStr(2048), nullable=True, server_default=text("'{}'"), comment="用户其他字段" ) diff --git a/server/model/team.py b/server/model/team.py index 32189554..21d00fb6 100644 --- a/server/model/team.py +++ b/server/model/team.py @@ -1,4 +1,4 @@ -from flask import abort +from flask import abort, session from sqlalchemy import and_, or_ from utils.utils import query_one_page @@ -122,6 +122,76 @@ def set_team_member(team_id, code_user_id, im_user_id): db.session.commit() +def create_team(app_info: dict) -> Team: + """Create a team. + + Args: + name (str): Team name. + app_info (dict): GitHub App info. + + Returns: + Team: Team object. + """ + + current_user_id = session.get("user_id", None) + if not current_user_id: + abort(403, "can not found user by id") + + new_team = Team( + id=ObjID.new_id(), + user_id=current_user_id, + name=app_info["account"]["login"], + description=None, + # extra=app_info, + ) + + db.session.add(new_team) + db.session.flush() + + # 创建 TeamMember + current_bind_user = BindUser.query.filter( + BindUser.user_id == current_user_id, + BindUser.status == 0, + ).first() + if not current_bind_user: + abort(403, "can not found bind user by id") + + new_team_member = TeamMember( + id=ObjID.new_id(), + team_id=new_team.id, + code_user_id=current_bind_user.id, + im_user_id=None, + ) + + db.session.add(new_team_member) + db.session.commit() + + return new_team + + +def create_code_application(team_id: str, installation_id: str) -> CodeApplication: + """Create a code application. + + Args: + team_id (str): Team ID. + installation_id (str): GitHub App installation ID. + + Returns: + CodeApplication: CodeApplication object. + """ + + new_code_application = CodeApplication( + id=ObjID.new_id(), + team_id=team_id, + installation_id=installation_id, + ) + + db.session.add(new_code_application) + db.session.commit() + + return new_code_application + + def save_im_application( team_id, platform, app_id, app_secret, encrypt_key, verification_token ): @@ -153,4 +223,4 @@ def save_im_application( ), ) ) - db.session.commit() + db.session.commit() \ No newline at end of file diff --git a/server/routes/github.py b/server/routes/github.py index 446d85ff..0df98d98 100644 --- a/server/routes/github.py +++ b/server/routes/github.py @@ -1,11 +1,15 @@ -import logging import os from app import app from flask import Blueprint, abort, redirect, request, session -from model.schema import Team +from model.team import create_code_application, create_team from utils.auth import authenticated -from utils.github.common import get_installation_token, get_jwt, verify_github_signature +from utils.github.application import ( + get_installation_info, + get_installation_token, + get_jwt, + verify_github_signature, +) from utils.user import register bp = Blueprint("github", __name__, url_prefix="/api/github") @@ -24,8 +28,37 @@ def github_install(): f"https://github.com/apps/{os.environ.get('GITHUB_APP_NAME')}/installations/new" ) + # 创建 Team + print(f"installation_id {installation_id}") -@bp.route("/register", methods=["GET"]) + jwt = get_jwt( + os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH"), + os.environ.get("GITHUB_APP_ID"), + ) + installation_token = get_installation_token(jwt, installation_id) + if installation_token is None: + app.logger.error("Failed to get installation token.") + return "Failed to get installation token." + + app_info = get_installation_info(jwt, installation_id) + if app_info is None: + app.logger.error("Failed to get installation info.") + return "Failed to get installation info." + + # 判断安装者的身份是用户还是组织 + type = app_info["account"]["type"] + if type == "user": + app.logger.error("User is not allowed to install.") + # TODO: 定义与前端的交互数据格式 + return "User is not allowed to install." + + team = create_team(app_info) + code_application = create_code_application(team.id, installation_id) + + return app_info + + +@bp.route("/oauth", methods=["GET"]) def github_register(): """GitHub OAuth register. @@ -57,9 +90,9 @@ def github_hook(): x_github_event = request.headers.get("x-github-event", None) - logging.info(x_github_event) + app.logger.info(x_github_event) - logging.debug(request.json) + app.logger.debug(request.json) return "Receive Success!" diff --git a/server/utils/github.py b/server/utils/github.py deleted file mode 100644 index 044c6b15..00000000 --- a/server/utils/github.py +++ /dev/null @@ -1,95 +0,0 @@ -import os -import time -from urllib.parse import parse_qs - -import httpx -from jwt import JWT, jwk_from_pem - - -def get_jwt(pem_path: str, app_id: str) -> str: - """Generate a JSON Web Token (JWT) for authentication. - - Args: - pem_path (str): path to the private key file. - app_id (str): GitHub App's identifier. - - Returns: - str: JWT. - """ - - # Open PEM - with open(pem_path, "rb") as pem_file: - signing_key = jwk_from_pem(pem_file.read()) - - payload = { - # Issued at time - "iat": int(time.time()), - # JWT expiration time (10 minutes maximum) - "exp": int(time.time()) + 600, - # GitHub App's identifier - "iss": app_id, - } - - # Create JWT - jwt_instance = JWT() - encoded_jwt = jwt_instance.encode(payload, signing_key, alg="RS256") - - return encoded_jwt - - -def get_installation_token(jwt: str, installation_id: str) -> str | None: - """Get installation access token - - Args: - jwt (str): The JSON Web Token used for authentication. - installation_id (str): The ID of the installation. - - Returns: - str: The installation access token. - """ - - with httpx.Client() as client: - response = client.post( - f"https://api.github.com/app/installations/{installation_id}/access_tokens", - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {jwt}", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) - if response.status_code == 200: - return None - - installation_token = response.json().get("token", None) - return installation_token - - return None - - -def register_by_code(code: str) -> str | None: - """Register by code - - Args: - code (str): The code returned by GitHub OAuth. - - Returns: - str: The user access token. - """ - - with httpx.Client() as client: - response = client.post( - "https://github.com/login/oauth/access_token", - params={ - "client_id": os.environ.get("GITHUB_CLIENT_ID"), - "client_secret": os.environ.get("GITHUB_CLIENT_SECRET"), - "code": code, - }, - ) - if response.status_code != 200: - return None - - access_token = parse_qs(response.text).get("access_token", None) - if access_token is not None: - return access_token[0] - - return None diff --git a/server/utils/github/account.py b/server/utils/github/account.py new file mode 100644 index 00000000..d064a356 --- /dev/null +++ b/server/utils/github/account.py @@ -0,0 +1,69 @@ +import httpx +from app import app + + +def get_user_info(access_token: str) -> dict | None: + """Get user info by access token. + + Args: + access_token (str): The user access token. + + Returns: + dict: User info. + """ + + with httpx.Client() as client: + response = client.get( + "https://api.github.com/user", + headers={ + "Accept": "application/vnd.github.v3+json", + "Authorization": f"token {access_token}", + }, + ) + if response.status_code != 200: + app.logger.debug(f"Failed to get user info. {response.text}") + return None + + user_info = response.json() + return user_info + + app.logger.debug("Failed to get user info.") + return None + + +def get_email(access_token: str) -> str | None: + """Get user email by access token. + + Args: + access_token (str): The user access token. + + Returns: + str: User email. + """ + + with httpx.Client() as client: + response = client.get( + "https://api.github.com/user/emails", + headers={ + "Accept": "application/vnd.github.v3+json", + "Authorization": f"Bearer {access_token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + if response.status_code != 200: + app.logger.debug(f"Failed to get user email. {response.text}") + return None + + user_emails = response.json() + if len(user_emails) == 0: + app.logger.debug("Failed to get user email.") + return None + + for user_email in user_emails: + if user_email["primary"]: + return user_email["email"] + + return user_emails[0]["email"] + + app.logger.debug("Failed to get user email.") + return None diff --git a/server/utils/github/common.py b/server/utils/github/application.py similarity index 79% rename from server/utils/github/common.py rename to server/utils/github/application.py index 4b3e811f..3e6c4275 100644 --- a/server/utils/github/common.py +++ b/server/utils/github/application.py @@ -1,12 +1,12 @@ import hashlib import hmac -import logging import os import time from functools import wraps from urllib.parse import parse_qs import httpx +from app import app from flask import abort, request from jwt import JWT, jwk_from_pem @@ -62,13 +62,38 @@ def get_installation_token(jwt: str, installation_id: str) -> str | None: "X-GitHub-Api-Version": "2022-11-28", }, ) - if response.status_code != 200: - logging.debug(f"Failed to get installation token. {response.text}") - return None installation_token = response.json().get("token", None) return installation_token + app.logger.debug(f"Failed to get installation token. {response.text}") + return None + + +def get_installation_info(jwt: str, installation_id: str) -> dict | None: + """Get installation info + Args: + jwt (str): The JSON Web Token used for authentication. + installation_id (str): The ID of the installation. + + Returns: + dict: The installation info. + """ + + with httpx.Client() as client: + response = client.get( + f"https://api.github.com/app/installations/{installation_id}", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {jwt}", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + + app_info = response.json() + return app_info + + app.logger.debug(f"Failed to get installation info. {response.text}") return None @@ -92,12 +117,13 @@ def oauth_by_code(code: str) -> dict | None: }, ) if response.status_code != 200: + app.logger.debug(f"Failed to get access token. {response.text}") return None try: oauth_info = parse_qs(response.text) except Exception as e: - logging.debug(e) + app.logger.debug(e) return None return oauth_info @@ -132,10 +158,10 @@ def wrapper(*args, **kwargs): ) expected_signature = "sha256=" + hash_object.hexdigest() - logging.debug(f"{expected_signature} {signature}") + app.logger.debug(f"{expected_signature} {signature}") if not hmac.compare_digest(expected_signature, signature): - logging.debug("Invalid signature.") + app.logger.debug("Invalid signature.") abort(403, "Invalid signature.") return func(*args, **kwargs) @@ -143,31 +169,3 @@ def wrapper(*args, **kwargs): return wrapper return decorator - - -def get_user_info(access_token: str): - """Get user info by access token. - - Args: - access_token (str): The user access token. - - Returns: - dict: User info. - """ - - with httpx.Client() as client: - response = client.get( - "https://api.github.com/user", - headers={ - "Accept": "application/vnd.github.v3+json", - "Authorization": f"token {access_token}", - }, - ) - if response.status_code != 200: - logging.debug(f"Failed to get user info. {response.text}") - return None - - user_info = response.json() - return user_info - - return None diff --git a/server/utils/user.py b/server/utils/user.py index 50edc4e4..eb112e7b 100644 --- a/server/utils/user.py +++ b/server/utils/user.py @@ -1,6 +1,8 @@ from app import app, db +from flask import abort from model.schema import BindUser, ObjID, User -from utils.github.common import get_user_info, oauth_by_code +from utils.github.account import get_email, get_user_info +from utils.github.application import oauth_by_code def register(code: str) -> str | None: @@ -10,6 +12,8 @@ def register(code: str) -> str | None: """ oauth_info = oauth_by_code(code) # 获取 access token + if oauth_info is None: + abort(500) access_token = oauth_info.get("access_token", None)[0] # 这里要考虑取哪个,为什么会有多个? @@ -23,12 +27,12 @@ def register(code: str) -> str | None: if user is not None: return user.id + email = get_email(access_token) + new_user = User( id=ObjID.new_id(), github_id=github_id, - email=user_info.get( - "email", None - ), # 这里的邮箱其实是公开邮箱,可能会获取不到 TODO: 换成使用用户邮箱 API 来获取 + email=email, # 这里的邮箱其实是公开邮箱,可能会获取不到 TODO: 换成使用用户邮箱 API 来获取 name=user_info.get("login", None), avatar=user_info.get("avatar_url", None), extra=user_info, @@ -41,8 +45,10 @@ def register(code: str) -> str | None: id=ObjID.new_id(), user_id=new_user.id, platform="github", - email=user_info.get("email", None), + email=email, + name=user_info.get("login", None), avatar=user_info.get("avatar_url", None), + access_token=access_token, extra=oauth_info, )