Дочинили авторизацию
This commit is contained in:
@@ -112,7 +112,7 @@ def upgrade() -> None:
|
|||||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||||
END IF;
|
END IF;
|
||||||
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
||||||
encode(digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
||||||
INSERT INTO han_app.sync_queue
|
INSERT INTO han_app.sync_queue
|
||||||
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||||
status, attempt_count, next_attempt_at, created_at, updated_at)
|
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Qualify pgcrypto digest in the contact sync trigger.
|
||||||
|
|
||||||
|
Revision ID: 0002_pgcrypto_digest
|
||||||
|
Revises: 0001_initial
|
||||||
|
Create Date: 2026-07-16
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0002_pgcrypto_digest"
|
||||||
|
down_revision: str | None = "0001_initial"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = han_app, pg_temp
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_entity_id uuid;
|
||||||
|
v_task_type text;
|
||||||
|
v_dedup text;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||||
|
v_entity_id := NEW.id;
|
||||||
|
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||||
|
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||||
|
ELSE
|
||||||
|
v_entity_id := NEW.user_id;
|
||||||
|
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||||
|
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||||
|
END IF;
|
||||||
|
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
||||||
|
encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
||||||
|
INSERT INTO han_app.sync_queue
|
||||||
|
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||||
|
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup,
|
||||||
|
jsonb_build_object('entity_id', v_entity_id), 'pending', 0,
|
||||||
|
now(), now(), now())
|
||||||
|
ON CONFLICT (dedup_key) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
raise RuntimeError("Contact sync trigger migration is forward-only")
|
||||||
@@ -357,7 +357,7 @@ async def ready(request: Request, db: Session):
|
|||||||
try:
|
try:
|
||||||
await db.execute(text("SELECT 1"))
|
await db.execute(text("SELECT 1"))
|
||||||
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
|
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
|
||||||
if revision != "0001_initial":
|
if revision != "0002_pgcrypto_digest":
|
||||||
raise RuntimeError("unexpected database revision")
|
raise RuntimeError("unexpected database revision")
|
||||||
await load_settings(db)
|
await load_settings(db)
|
||||||
components["postgres"] = "ok"
|
components["postgres"] = "ok"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useApp } from "../../src/app-context";
|
import { useApp } from "../../src/app-context";
|
||||||
import type { Consents } from "../../src/types";
|
import type { Consents } from "../../src/types";
|
||||||
@@ -9,6 +9,7 @@ export default function AuthCallbackScreen() {
|
|||||||
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
|
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<unknown>();
|
||||||
|
const completionStarted = useRef(false);
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
if (!params.code || !params.state || typeof window === "undefined") return;
|
if (!params.code || !params.state || typeof window === "undefined") return;
|
||||||
const raw = window.sessionStorage.getItem("han.pending-consents");
|
const raw = window.sessionStorage.getItem("han.pending-consents");
|
||||||
@@ -17,11 +18,14 @@ export default function AuthCallbackScreen() {
|
|||||||
void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError);
|
void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError);
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (completionStarted.current) return;
|
||||||
if (params.error) {
|
if (params.error) {
|
||||||
|
completionStarted.current = true;
|
||||||
setError(new Error("Авторизация отменена или отклонена."));
|
setError(new Error("Авторизация отменена или отклонена."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!params.code || !params.state) return;
|
if (!params.code || !params.state) return;
|
||||||
|
completionStarted.current = true;
|
||||||
const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null;
|
const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null;
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
setError(new Error("Не найдены локально принятые согласия. Начните вход заново."));
|
setError(new Error("Не найдены локально принятые согласия. Начните вход заново."));
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const secureStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
|
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
|
||||||
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "oauth/callback" });
|
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
|
||||||
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
|
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
|
||||||
|
|
||||||
export function configureAuthFailure(callback: () => void) {
|
export function configureAuthFailure(callback: () => void) {
|
||||||
@@ -95,6 +95,10 @@ export async function beginAuthorization() {
|
|||||||
state,
|
state,
|
||||||
nonce,
|
nonce,
|
||||||
})}`;
|
})}`;
|
||||||
|
if (Platform.OS === "web" && typeof window !== "undefined") {
|
||||||
|
window.location.assign(url);
|
||||||
|
return new Promise<never>(() => undefined);
|
||||||
|
}
|
||||||
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
|
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
|
||||||
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
|
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
|
||||||
const callback = new URL(result.url);
|
const callback = new URL(result.url);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
"frontchannelLogout": true,
|
"frontchannelLogout": true,
|
||||||
"fullScopeAllowed": false,
|
"fullScopeAllowed": false,
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"https://chat.han0107.ru/oauth/callback",
|
"https://chat.han0107.ru/auth/callback",
|
||||||
"han-chat://auth/callback"
|
"han-chat://auth/callback"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
@@ -74,6 +74,20 @@
|
|||||||
"use.refresh.tokens": "true",
|
"use.refresh.tokens": "true",
|
||||||
"client.use.lightweight.access.token.enabled": "false"
|
"client.use.lightweight.access.token.enabled": "false"
|
||||||
},
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "subject",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-sub-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"introspection.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"defaultClientScopes": ["phone", "han-chat-api"],
|
"defaultClientScopes": ["phone", "han-chat-api"],
|
||||||
"optionalClientScopes": ["offline_access"]
|
"optionalClientScopes": ["offline_access"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class RealmContractTest {
|
|||||||
assertTrue(realm.contains("\"included.client.audience\": \"han-chat-api\""));
|
assertTrue(realm.contains("\"included.client.audience\": \"han-chat-api\""));
|
||||||
assertTrue(realm.contains("\"claim.name\": \"phone_number\""));
|
assertTrue(realm.contains("\"claim.name\": \"phone_number\""));
|
||||||
assertTrue(realm.contains("\"claim.name\": \"phone_number_verified\""));
|
assertTrue(realm.contains("\"claim.name\": \"phone_number_verified\""));
|
||||||
|
assertTrue(realm.contains("\"protocolMapper\": \"oidc-sub-mapper\""));
|
||||||
assertTrue(realm.contains("\"revokeRefreshToken\": true"));
|
assertTrue(realm.contains("\"revokeRefreshToken\": true"));
|
||||||
assertTrue(realm.contains("\"refreshTokenMaxReuse\": 0"));
|
assertTrue(realm.contains("\"refreshTokenMaxReuse\": 0"));
|
||||||
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
|
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ server {
|
|||||||
proxy_pass http://api_backend;
|
proxy_pass http://api_backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /auth/callback {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files /index.html =404;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
include /etc/nginx/generated/security-headers.conf;
|
||||||
|
}
|
||||||
location ^~ /auth/resources/ {
|
location ^~ /auth/resources/ {
|
||||||
include /etc/nginx/snippets/proxy-keycloak.conf;
|
include /etc/nginx/snippets/proxy-keycloak.conf;
|
||||||
proxy_pass http://keycloak_upstream;
|
proxy_pass http://keycloak_upstream;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
self.assertNotIn("proxy_read_timeout", proxy_common)
|
self.assertNotIn("proxy_read_timeout", proxy_common)
|
||||||
self.assertNotIn("proxy_send_timeout", proxy_common)
|
self.assertNotIn("proxy_send_timeout", proxy_common)
|
||||||
self.assertIn("include /etc/nginx/snippets/proxy-keycloak.conf;", site)
|
self.assertIn("include /etc/nginx/snippets/proxy-keycloak.conf;", site)
|
||||||
|
self.assertIn("location = /auth/callback", site)
|
||||||
self.assertIn("location ^~ /auth/resources/", site)
|
self.assertIn("location ^~ /auth/resources/", site)
|
||||||
self.assertIn("protocol/openid-connect/3p-cookies/", site)
|
self.assertIn("protocol/openid-connect/3p-cookies/", site)
|
||||||
self.assertNotIn("security-headers.conf", proxy_keycloak)
|
self.assertNotIn("security-headers.conf", proxy_keycloak)
|
||||||
@@ -151,9 +152,17 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_expo_public_environment_uses_static_property_access(self) -> None:
|
def test_expo_public_environment_uses_static_property_access(self) -> None:
|
||||||
config = (ROOT / "frontend-test-site/src/config.ts").read_text(encoding="utf-8")
|
config = (ROOT / "frontend-test-site/src/config.ts").read_text(encoding="utf-8")
|
||||||
|
auth = (ROOT / "frontend-test-site/src/auth.ts").read_text(encoding="utf-8")
|
||||||
|
callback = (ROOT / "frontend-test-site/app/auth/callback.tsx").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
self.assertNotIn("process.env[name]", config)
|
self.assertNotIn("process.env[name]", config)
|
||||||
self.assertIn("process.env.EXPO_PUBLIC_API_BASE_URL", config)
|
self.assertIn("process.env.EXPO_PUBLIC_API_BASE_URL", config)
|
||||||
self.assertIn("process.env.EXPO_PUBLIC_AUTH_BASE_URL", config)
|
self.assertIn("process.env.EXPO_PUBLIC_AUTH_BASE_URL", config)
|
||||||
|
self.assertIn('path: "auth/callback"', auth)
|
||||||
|
self.assertIn('window.location.assign(url)', auth)
|
||||||
|
self.assertIn("completionStarted.current", callback)
|
||||||
|
self.assertTrue((ROOT / "frontend-test-site/app/auth/callback.tsx").is_file())
|
||||||
|
|
||||||
def test_alembic_escapes_percent_encoded_dsn_options(self) -> None:
|
def test_alembic_escapes_percent_encoded_dsn_options(self) -> None:
|
||||||
for relative_path in (
|
for relative_path in (
|
||||||
@@ -165,6 +174,19 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
self.assertIn('.replace("%", "%%")', env_script, relative_path)
|
self.assertIn('.replace("%", "%%")', env_script, relative_path)
|
||||||
self.assertIn("create_postgres_engine", env_script, relative_path)
|
self.assertIn("create_postgres_engine", env_script, relative_path)
|
||||||
|
|
||||||
|
def test_contact_sync_qualifies_pgcrypto_digest(self) -> None:
|
||||||
|
initial = (
|
||||||
|
ROOT / "api-backend/alembic/versions/0001_initial_han_app.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
fix = (
|
||||||
|
ROOT / "api-backend/alembic/versions/0002_qualify_pgcrypto_digest.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
main = (ROOT / "api-backend/app/main.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("public.digest(", initial)
|
||||||
|
self.assertIn("public.digest(", fix)
|
||||||
|
self.assertIn('down_revision: str | None = "0001_initial"', fix)
|
||||||
|
self.assertIn('revision != "0002_pgcrypto_digest"', main)
|
||||||
|
|
||||||
def test_keycloak_management_health_and_bridge_environment(self) -> None:
|
def test_keycloak_management_health_and_bridge_environment(self) -> None:
|
||||||
standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8")
|
standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8")
|
||||||
self.assertIn("GET /auth/health/ready", standalone)
|
self.assertIn("GET /auth/health/ready", standalone)
|
||||||
|
|||||||
Reference in New Issue
Block a user