TOMOCHIN4 Claude Opus 4.5 commited on
Commit
a8a564b
·
1 Parent(s): 0878a96

feat: 認証機能バックエンド実装 (Phase 1.1-1.3)

Browse files

- 招待コード検証: 新規登録時にINVITE_CODE環境変数と照合
- パスワードハッシュ化: bcryptによるパスワード管理
- ログインエンドポイント: /api/login 追加
- GAS認証API: registerUser/loginUser対応

新規ファイル:
- src/services/auth_service.py

修正ファイル:
- app.py: RegisterUserRequest拡張、LoginRequest追加
- src/services/gas_client.py: login()メソッド追加
- gas/Code.gs: password_hash列追加、loginUser関数追加
- requirements.txt: bcrypt追加

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

__pycache__/app.cpython-310.pyc ADDED
Binary file (12.6 kB). View file
 
app.py CHANGED
@@ -23,6 +23,7 @@ load_dotenv()
23
  from src.services.gemini_service import GeminiService
24
  from src.services.gas_client import GASClient
25
  from src.services.knowledge_service import KnowledgeService
 
26
 
27
  # ロギング設定
28
  logging.basicConfig(level=logging.INFO)
@@ -47,6 +48,8 @@ knowledge_service = KnowledgeService()
47
 
48
  class RegisterUserRequest(BaseModel):
49
  username: str
 
 
50
 
51
  class StartSessionRequest(BaseModel):
52
  user_id: str
@@ -77,6 +80,10 @@ class GetEvaluationRequest(BaseModel):
77
  session_id: str
78
  subjects: Optional[List[str]] = None
79
 
 
 
 
 
80
 
81
  # =============================================================================
82
  # APIエンドポイント
@@ -100,13 +107,84 @@ async def health_check():
100
  async def register_user(request: RegisterUserRequest):
101
  """ユーザー登録"""
102
  try:
103
- result = await gas_client.register_user(request.username)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  return result
 
 
 
105
  except Exception as e:
106
  logger.error(f"register_user error: {e}")
107
  raise HTTPException(status_code=500, detail=str(e))
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  @app.post("/api/start_session")
111
  async def start_session(request: StartSessionRequest):
112
  """セッション開始"""
 
23
  from src.services.gemini_service import GeminiService
24
  from src.services.gas_client import GASClient
25
  from src.services.knowledge_service import KnowledgeService
26
+ from src.services.auth_service import AuthService
27
 
28
  # ロギング設定
29
  logging.basicConfig(level=logging.INFO)
 
48
 
49
  class RegisterUserRequest(BaseModel):
50
  username: str
51
+ password: str
52
+ invite_code: str
53
 
54
  class StartSessionRequest(BaseModel):
55
  user_id: str
 
80
  session_id: str
81
  subjects: Optional[List[str]] = None
82
 
83
+ class LoginRequest(BaseModel):
84
+ username: str
85
+ password: str
86
+
87
 
88
  # =============================================================================
89
  # APIエンドポイント
 
107
  async def register_user(request: RegisterUserRequest):
108
  """ユーザー登録"""
109
  try:
110
+ # 招待コード検証
111
+ valid_invite_code = os.environ.get("INVITE_CODE", "")
112
+
113
+ if not valid_invite_code:
114
+ logger.error("register_user: INVITE_CODE environment variable is not set")
115
+ raise HTTPException(status_code=403, detail="招待コードが設定されていません")
116
+
117
+ if request.invite_code != valid_invite_code:
118
+ logger.warning(f"register_user: Invalid invite code attempt for user '{request.username}'")
119
+ raise HTTPException(status_code=403, detail="招待コードが無効です")
120
+
121
+ logger.info(f"register_user: Invite code verified for user '{request.username}'")
122
+
123
+ # パスワードをハッシュ化
124
+ hashed_password = AuthService.hash_password(request.password)
125
+ logger.info(f"register_user: Password hashed for user '{request.username}'")
126
+
127
+ # GAS連携処理(ハッシュ化されたパスワードを渡す)
128
+ result = await gas_client.register_user(request.username, hashed_password)
129
+
130
+ # 既存ユーザーの場合はエラー
131
+ if result.get("data", {}).get("is_new") == False:
132
+ raise HTTPException(status_code=409, detail="このユーザー名は既に登録されています")
133
+
134
  return result
135
+ except HTTPException:
136
+ # HTTPExceptionはそのまま再送出
137
+ raise
138
  except Exception as e:
139
  logger.error(f"register_user error: {e}")
140
  raise HTTPException(status_code=500, detail=str(e))
141
 
142
 
143
+ @app.post("/api/login")
144
+ async def login(request: LoginRequest):
145
+ """ログイン"""
146
+ try:
147
+ # GASからユーザー情報取得
148
+ result = await gas_client.login(request.username)
149
+
150
+ data = result.get("data", {})
151
+
152
+ # ユーザーが見つからない場合
153
+ if not data.get("found"):
154
+ logger.warning(f"login: User not found: '{request.username}'")
155
+ raise HTTPException(status_code=401, detail="ユーザー名またはパスワードが正しくありません")
156
+
157
+ stored_hash = data.get("password_hash")
158
+
159
+ # パスワード未設定の既存ユーザー(v1.0からの移行ユーザー)
160
+ if not stored_hash:
161
+ logger.info(f"login: User '{request.username}' needs password migration")
162
+ raise HTTPException(
163
+ status_code=403,
164
+ detail="パスワードが未設定です。新規登録画面からパスワードを設定してください。"
165
+ )
166
+
167
+ # パスワード検証
168
+ if not AuthService.verify_password(request.password, stored_hash):
169
+ logger.warning(f"login: Invalid password for user '{request.username}'")
170
+ raise HTTPException(status_code=401, detail="ユーザー名またはパスワードが正しくありません")
171
+
172
+ logger.info(f"login: User '{request.username}' logged in successfully")
173
+
174
+ return {
175
+ "success": True,
176
+ "data": {
177
+ "user_id": data.get("user_id"),
178
+ "username": data.get("username")
179
+ }
180
+ }
181
+ except HTTPException:
182
+ raise
183
+ except Exception as e:
184
+ logger.error(f"login error: {e}")
185
+ raise HTTPException(status_code=500, detail=str(e))
186
+
187
+
188
  @app.post("/api/start_session")
189
  async def start_session(request: StartSessionRequest):
190
  """セッション開始"""
requirements.txt CHANGED
@@ -13,3 +13,6 @@ python-dotenv==1.0.1
13
 
14
  # JSON Processing
15
  orjson==3.10.12
 
 
 
 
13
 
14
  # JSON Processing
15
  orjson==3.10.12
16
+
17
+ # Password Hashing
18
+ bcrypt==4.2.1
src/services/__pycache__/auth_service.cpython-310.pyc ADDED
Binary file (2.64 kB). View file
 
src/services/__pycache__/gas_client.cpython-310.pyc CHANGED
Binary files a/src/services/__pycache__/gas_client.cpython-310.pyc and b/src/services/__pycache__/gas_client.cpython-310.pyc differ
 
src/services/auth_service.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 認証サービス - パスワードハッシュ化・検証
3
+
4
+ bcryptを使用した安全なパスワード管理機能を提供
5
+ """
6
+ import bcrypt
7
+ import logging
8
+ from typing import Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class AuthService:
14
+ """認証関連のサービス - パスワードハッシュ化と検証"""
15
+
16
+ @staticmethod
17
+ def hash_password(password: str) -> str:
18
+ """
19
+ パスワードをbcryptでハッシュ化
20
+
21
+ Args:
22
+ password: 平文パスワード
23
+
24
+ Returns:
25
+ str: ハッシュ化されたパスワード(UTF-8文字列)
26
+
27
+ Raises:
28
+ ValueError: パスワードが空文字列の場合
29
+ """
30
+ if not password or not password.strip():
31
+ raise ValueError("Password cannot be empty")
32
+
33
+ try:
34
+ # ソルト生成とハッシュ化
35
+ salt = bcrypt.gensalt()
36
+ hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
37
+ return hashed.decode('utf-8')
38
+ except Exception as e:
39
+ logger.error(f"Password hashing error: {e}")
40
+ raise
41
+
42
+ @staticmethod
43
+ def verify_password(password: str, hashed: str) -> bool:
44
+ """
45
+ パスワードを検証
46
+
47
+ Args:
48
+ password: 平文パスワード
49
+ hashed: ハッシュ化されたパスワード
50
+
51
+ Returns:
52
+ bool: パスワードが一致すればTrue、それ以外はFalse
53
+ """
54
+ if not password or not hashed:
55
+ logger.warning("verify_password: Empty password or hash provided")
56
+ return False
57
+
58
+ try:
59
+ return bcrypt.checkpw(
60
+ password.encode('utf-8'),
61
+ hashed.encode('utf-8')
62
+ )
63
+ except Exception as e:
64
+ logger.error(f"Password verification error: {e}")
65
+ return False
66
+
67
+ @staticmethod
68
+ def validate_password_strength(password: str) -> tuple[bool, Optional[str]]:
69
+ """
70
+ パスワード強度を検証(オプション)
71
+
72
+ Args:
73
+ password: 検証するパスワード
74
+
75
+ Returns:
76
+ tuple[bool, Optional[str]]: (検証結果, エラーメッセージ)
77
+ """
78
+ if not password:
79
+ return False, "パスワードが空です"
80
+
81
+ if len(password) < 8:
82
+ return False, "パスワードは8文字以上必要です"
83
+
84
+ # 追加の強度チェック(必要に応じて)
85
+ # has_upper = any(c.isupper() for c in password)
86
+ # has_lower = any(c.islower() for c in password)
87
+ # has_digit = any(c.isdigit() for c in password)
88
+
89
+ return True, None
src/services/gas_client.py CHANGED
@@ -56,9 +56,32 @@ class GASClient:
56
  logger.info(f"GAS API response: success={result.get('success', result.get('status'))}")
57
  return result
58
 
59
- async def register_user(self, username: str) -> Dict[str, Any]:
60
- """ユーザー登録/ログイン"""
61
- return await self._call_api("register_user", {"username": username})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  async def start_session(self, user_id: str, subjects: List[str]) -> Dict[str, Any]:
64
  """セッション開始"""
 
56
  logger.info(f"GAS API response: success={result.get('success', result.get('status'))}")
57
  return result
58
 
59
+ async def register_user(self, username: str, hashed_password: Optional[str] = None) -> Dict[str, Any]:
60
+ """
61
+ ユーザー登録
62
+
63
+ Args:
64
+ username: ユーザー名
65
+ hashed_password: ハッシュ化されたパスワード(省略可)
66
+ """
67
+ params = {"username": username}
68
+ if hashed_password:
69
+ params["password_hash"] = hashed_password # GAS側のパラメータ名に合わせる
70
+ return await self._call_api("register_user", params)
71
+
72
+ async def login(self, username: str) -> Dict[str, Any]:
73
+ """
74
+ ログイン(パスワードハッシュ取得)
75
+
76
+ Args:
77
+ username: ユーザー名
78
+
79
+ Returns:
80
+ found: bool - ユーザーが見つかったか
81
+ user_id: str - ユーザーID(見つかった場合)
82
+ password_hash: str - パスワードハッシュ(見つかった場合)
83
+ """
84
+ return await self._call_api("login", {"username": username})
85
 
86
  async def start_session(self, user_id: str, subjects: List[str]) -> Dict[str, Any]:
87
  """セッション開始"""