prepare( "SELECT userID, passwordHash, role, entityID, mustChangePassword FROM users WHERE username = :u" ); $stmt->execute([':u' => $username]); $user = $stmt->fetch(PDO::FETCH_ASSOC); if (!$user || !password_verify($password, $user['passwordHash'])) { qdb_discard($tmpDb, $lockFp); $retryAfter = qdb_login_rate_limit_record_failure($username, $securitySettings); if ($retryAfter > 0) { qdb_login_rate_limit_deny($retryAfter); } json_error('INVALID_CREDENTIALS', 'Invalid username or password', 401); } qdb_login_rate_limit_clear($username); $assignedClients = []; if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') { $cStmt = $pdo->prepare( "SELECT clientCode FROM client WHERE coachID = :cid AND COALESCE(archived, 0) = 0 ORDER BY clientCode" ); $cStmt->execute([':cid' => $user['entityID']]); $assignedClients = array_map( fn($code) => ['clientCode' => $code], $cStmt->fetchAll(PDO::FETCH_COLUMN) ); } qdb_discard($tmpDb, $lockFp); qdb_reject_coach_web_login((string)($user['role'] ?? '')); if ((int)$user['mustChangePassword'] === 1) { $tempToken = bin2hex(random_bytes(32)); token_add($tempToken, qdb_session_ttl_seconds($securitySettings, true), [ 'role' => $user['role'], 'entityID' => $user['entityID'], 'userID' => $user['userID'], 'temp' => true, ]); json_success([ 'mustChangePassword' => true, 'token' => $tempToken, 'user' => $username, 'role' => $user['role'], ]); } $token = bin2hex(random_bytes(32)); token_add($token, qdb_session_ttl_seconds($securitySettings, false), [ 'role' => $user['role'], 'entityID' => $user['entityID'], 'userID' => $user['userID'], ]); $loginData = [ 'token' => $token, 'user' => $username, 'role' => $user['role'], ]; if ($assignedClients !== []) { $loginData['clientsPayload'] = qdb_sensitive_envelope( json_encode(['clients' => $assignedClients], JSON_UNESCAPED_UNICODE), $token ); } json_success($loginData); } catch (Throwable $e) { qdb_handler_fail($e, 'auth', null, $tmpDb ?? null, $lockFp ?? null); } break; case 'auth/change-password': if ($method !== 'POST') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } $bearerToken = get_bearer_token(); if (!$bearerToken) { json_error('UNAUTHORIZED', 'Bearer token required', 401); } $tokenRec = token_get_record($bearerToken); if (!$tokenRec) { json_error('FORBIDDEN', 'Invalid or expired token', 403); } $body = read_json_body(); $username = trim($body['username'] ?? ''); $oldPassword = (string)($body['old_password'] ?? ''); $newPassword = (string)($body['new_password'] ?? ''); if ($username === '' || $oldPassword === '' || $newPassword === '') { json_error('MISSING_FIELDS', 'username, old_password, and new_password are required', 400); } if (strlen($newPassword) < 6) { json_error('PASSWORD_TOO_SHORT', 'New password must be at least 6 characters', 400); } if (($tokenRec['userID'] ?? '') === '') { json_error('FORBIDDEN', 'Token not associated with a user', 403); } try { [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); $stmt = $pdo->prepare( "SELECT userID, passwordHash, role, entityID FROM users WHERE username = :u" ); $stmt->execute([':u' => $username]); $user = $stmt->fetch(PDO::FETCH_ASSOC); if (!$user) { qdb_discard($tmpDb, $lockFp); json_error('INVALID_CREDENTIALS', 'Invalid credentials', 401); } if ($user['userID'] !== $tokenRec['userID']) { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Token does not match user', 403); } qdb_reject_coach_web_login((string)($user['role'] ?? '')); if (!password_verify($oldPassword, $user['passwordHash'])) { qdb_discard($tmpDb, $lockFp); json_error('INVALID_CREDENTIALS', 'Old password incorrect', 401); } $newHash = password_hash($newPassword, PASSWORD_DEFAULT); $pdo->prepare( "UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid" )->execute([':h' => $newHash, ':uid' => $user['userID']]); token_revoke_all_for_user_on_pdo($pdo, $user['userID']); qdb_save($tmpDb, $lockFp); $securitySettings = qdb_settings_get(); // Issue a fresh session for this browser only $newToken = bin2hex(random_bytes(32)); token_add($newToken, qdb_session_ttl_seconds($securitySettings, false), [ 'role' => $user['role'], 'entityID' => $user['entityID'], 'userID' => $user['userID'], ]); json_success([ 'token' => $newToken, 'user' => $username, 'role' => $user['role'], ]); } catch (Throwable $e) { qdb_handler_fail($e, 'auth', null, $tmpDb ?? null, $lockFp ?? null); } break; case 'auth/keycloak-config': if ($method !== 'GET') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } $kcConfig = qdb_keycloak_config(); json_success([ 'enabled' => qdb_keycloak_is_configured($kcConfig), 'displayName' => $kcConfig['display_name'], 'loginUrl' => qdb_keycloak_is_configured($kcConfig) ? 'auth/keycloak-login' : '', ]); break; case 'auth/keycloak-login': if ($method !== 'GET') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } $kcConfig = qdb_keycloak_config(); if (!qdb_keycloak_is_configured($kcConfig)) { json_error('KEYCLOAK_NOT_CONFIGURED', 'University login is not configured', 503); } try { $discovery = qdb_keycloak_discovery($kcConfig); $nonce = bin2hex(random_bytes(16)); $params = [ 'client_id' => $kcConfig['client_id'], 'redirect_uri' => $kcConfig['redirect_uri'], 'response_type' => 'code', 'scope' => 'openid profile email', 'state' => qdb_keycloak_create_state($nonce), 'nonce' => $nonce, ]; qdb_keycloak_redirect($discovery['authorization_endpoint'] . '?' . http_build_query($params)); } catch (Throwable $e) { qdb_handler_fail($e, 'keycloak-login', null, null, null); } break; case 'auth/keycloak-callback': if ($method !== 'GET') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } if (isset($_GET['error'])) { json_error('KEYCLOAK_DENIED', 'University login was cancelled or denied', 401); } $code = trim((string)($_GET['code'] ?? '')); $state = trim((string)($_GET['state'] ?? '')); if ($code === '' || $state === '') { json_error('MISSING_FIELDS', 'Keycloak callback requires code and state', 400); } $kcConfig = qdb_keycloak_config(); if (!qdb_keycloak_is_configured($kcConfig)) { json_error('KEYCLOAK_NOT_CONFIGURED', 'University login is not configured', 503); } try { qdb_keycloak_verify_state($state); $discovery = qdb_keycloak_discovery($kcConfig); $tokenResponse = qdb_keycloak_exchange_code($kcConfig, $discovery, $code); $accessToken = (string)($tokenResponse['access_token'] ?? ''); if ($accessToken === '') { json_error('KEYCLOAK_TOKEN_FAILED', 'University login did not return an access token', 401); } $claims = qdb_keycloak_userinfo($discovery, $accessToken); $username = qdb_keycloak_claim_username($kcConfig, $claims); [$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail(); $stmt = $pdo->prepare( "SELECT userID, role, entityID, mustChangePassword FROM users WHERE username = :u" ); $stmt->execute([':u' => $username]); $user = $stmt->fetch(PDO::FETCH_ASSOC); qdb_discard($tmpDb, $lockFp); if (!$user) { json_error('KEYCLOAK_USER_NOT_ALLOWED', 'University account is not registered for this application', 403); } qdb_reject_coach_web_login((string)($user['role'] ?? '')); if ((int)($user['mustChangePassword'] ?? 0) === 1) { json_error('PASSWORD_CHANGE_REQUIRED', 'Please sign in locally once to change your temporary password before using University login', 403); } $securitySettings = qdb_settings_get(); $token = bin2hex(random_bytes(32)); token_add($token, qdb_session_ttl_seconds($securitySettings, false), [ 'role' => $user['role'], 'entityID' => $user['entityID'], 'userID' => $user['userID'], ]); qdb_keycloak_finish_html($token, $username, (string)$user['role']); } catch (Throwable $e) { qdb_handler_fail($e, 'keycloak-callback', null, $tmpDb ?? null, $lockFp ?? null); } break; default: json_error('NOT_FOUND', 'Unknown auth route', 404); }