Working use as third party resource provider
This commit is contained in:
parent
1c57944a90
commit
435c11b6ca
7 changed files with 138 additions and 59 deletions
src/oidc_test
|
@ -7,7 +7,7 @@ from pydantic import ConfigDict
|
||||||
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
|
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from oidc_test.settings import AuthProviderSettings, settings
|
from oidc_test.settings import AuthProviderSettings, ResourceProvider, Resource, settings
|
||||||
from oidc_test.models import User
|
from oidc_test.models import User
|
||||||
|
|
||||||
logger = logging.getLogger("oidc-test")
|
logger = logging.getLogger("oidc-test")
|
||||||
|
@ -24,6 +24,7 @@ class Provider(AuthProviderSettings):
|
||||||
authlib_client: StarletteOAuth2App = StarletteOAuth2App(None)
|
authlib_client: StarletteOAuth2App = StarletteOAuth2App(None)
|
||||||
info: dict[str, Any] = {}
|
info: dict[str, Any] = {}
|
||||||
unknown_auth_user: User
|
unknown_auth_user: User
|
||||||
|
logout_with_id_token_hint: bool = True
|
||||||
|
|
||||||
def decode(self, token: str, verify_signature: bool | None = None) -> dict[str, Any]:
|
def decode(self, token: str, verify_signature: bool | None = None) -> dict[str, Any]:
|
||||||
"""Decode the token with signature check"""
|
"""Decode the token with signature check"""
|
||||||
|
@ -89,3 +90,18 @@ class Provider(AuthProviderSettings):
|
||||||
|
|
||||||
def get_session_key(self, userinfo):
|
def get_session_key(self, userinfo):
|
||||||
return userinfo[self.session_key]
|
return userinfo[self.session_key]
|
||||||
|
|
||||||
|
def get_resource(self, resource_name: str) -> Resource:
|
||||||
|
return [
|
||||||
|
resource for resource in self.resources if resource.resource_name == resource_name
|
||||||
|
][0]
|
||||||
|
|
||||||
|
def get_resource_url(self, resource_name: str) -> str:
|
||||||
|
return self.url + self.get_resource(resource_name).url
|
||||||
|
|
||||||
|
def get_resource_provider(self, resource_provider_id: str) -> ResourceProvider:
|
||||||
|
return [
|
||||||
|
provider
|
||||||
|
for provider in self.resource_providers
|
||||||
|
if provider.id == resource_provider_id
|
||||||
|
][0]
|
||||||
|
|
|
@ -107,7 +107,6 @@ async def home(
|
||||||
context["resources"] = None
|
context["resources"] = None
|
||||||
else:
|
else:
|
||||||
context["access_token"] = token["access_token"]
|
context["access_token"] = token["access_token"]
|
||||||
# XXX: resources defined externally? I am confused...
|
|
||||||
try:
|
try:
|
||||||
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
|
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
|
||||||
except PyJWTError as err:
|
except PyJWTError as err:
|
||||||
|
@ -118,7 +117,8 @@ async def home(
|
||||||
context["access_token_scope"] = None
|
context["access_token_scope"] = None
|
||||||
context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
|
context["id_token_parsed"] = provider.decode(token["id_token"], verify_signature=False)
|
||||||
context["access_token_parsed"] = access_token_parsed
|
context["access_token_parsed"] = access_token_parsed
|
||||||
context["resource_providers"] = registry.resource_providers
|
context["resources"] = registry.resources
|
||||||
|
context["resource_providers"] = provider.resource_providers
|
||||||
try:
|
try:
|
||||||
context["refresh_token_parsed"] = provider.decode(
|
context["refresh_token_parsed"] = provider.decode(
|
||||||
token["refresh_token"], verify_signature=False
|
token["refresh_token"], verify_signature=False
|
||||||
|
@ -246,7 +246,7 @@ async def logout(
|
||||||
if (
|
if (
|
||||||
provider_logout_uri := provider.authlib_client.server_metadata.get("end_session_endpoint")
|
provider_logout_uri := provider.authlib_client.server_metadata.get("end_session_endpoint")
|
||||||
) is None:
|
) is None:
|
||||||
logger.warn(f"Cannot find end_session_endpoint for provider {provider.id}")
|
logger.warning(f"Cannot find end_session_endpoint for provider {provider.id}")
|
||||||
return RedirectResponse(request.url_for("non_compliant_logout"))
|
return RedirectResponse(request.url_for("non_compliant_logout"))
|
||||||
post_logout_uri = request.url_for("home")
|
post_logout_uri = request.url_for("home")
|
||||||
# Clear session
|
# Clear session
|
||||||
|
@ -255,19 +255,15 @@ async def logout(
|
||||||
try:
|
try:
|
||||||
token = await db.get_token(provider, request.session.pop("sid", None))
|
token = await db.get_token(provider, request.session.pop("sid", None))
|
||||||
except TokenNotInDb:
|
except TokenNotInDb:
|
||||||
logger.warn("No session in db for the token or no token")
|
logger.warning("No session in db for the token or no token")
|
||||||
return RedirectResponse(request.url_for("home"))
|
return RedirectResponse(request.url_for("home"))
|
||||||
logout_url = (
|
url_query = {
|
||||||
provider_logout_uri
|
"post_logout_redirect_uri": post_logout_uri,
|
||||||
+ "?"
|
"client_id": provider.client_id,
|
||||||
+ urlencode(
|
}
|
||||||
{
|
if provider.logout_with_id_token_hint:
|
||||||
"post_logout_redirect_uri": post_logout_uri,
|
url_query["id_token_hint"] = token["id_token"]
|
||||||
"id_token_hint": token["id_token"],
|
logout_url = f"{provider_logout_uri}?{urlencode(url_query)}"
|
||||||
"client_id": "oidc_local_test",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return RedirectResponse(logout_url)
|
return RedirectResponse(logout_url)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ class ProcessError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ResourceProvider(BaseModel):
|
class Resource(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
scope_required: str | None = None
|
scope_required: str | None = None
|
||||||
role_required: str | None = None
|
role_required: str | None = None
|
||||||
|
@ -35,13 +35,13 @@ class ResourceProvider(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ResourceRegistry(BaseModel):
|
class ResourceRegistry(BaseModel):
|
||||||
resource_providers: dict[str, ResourceProvider] = {}
|
resources: dict[str, Resource] = {}
|
||||||
|
|
||||||
def make_registry(self):
|
def make_registry(self):
|
||||||
for ep in entry_points().select(group="oidc_test.resource_provider"):
|
for ep in entry_points().select(group="oidc_test.resource_provider"):
|
||||||
ResourceProviderClass = ep.load()
|
ResourceClass = ep.load()
|
||||||
if issubclass(ResourceProviderClass, ResourceProvider):
|
if issubclass(ResourceClass, Resource):
|
||||||
self.resource_providers[ep.name] = ResourceProviderClass(ep.name)
|
self.resources[ep.name] = ResourceClass(ep.name)
|
||||||
|
|
||||||
|
|
||||||
registry = ResourceRegistry()
|
registry = ResourceRegistry()
|
||||||
|
|
|
@ -17,7 +17,7 @@ from oidc_test.auth.utils import (
|
||||||
oauth2_scheme_optional,
|
oauth2_scheme_optional,
|
||||||
)
|
)
|
||||||
from oidc_test.auth_providers import providers
|
from oidc_test.auth_providers import providers
|
||||||
from oidc_test.settings import settings
|
from oidc_test.settings import ResourceProvider, settings
|
||||||
from oidc_test.models import User
|
from oidc_test.models import User
|
||||||
from oidc_test.registry import ProcessError, ProcessResult, registry
|
from oidc_test.registry import ProcessError, ProcessResult, registry
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ resource_server.add_middleware(
|
||||||
|
|
||||||
@resource_server.get("/")
|
@resource_server.get("/")
|
||||||
async def resources() -> dict[str, dict[str, Any]]:
|
async def resources() -> dict[str, dict[str, Any]]:
|
||||||
return {"internal": {}, "plugins": registry.resource_providers}
|
return {"internal": {}, "plugins": registry.resources}
|
||||||
|
|
||||||
|
|
||||||
@resource_server.get("/{resource_name}")
|
@resource_server.get("/{resource_name}")
|
||||||
|
@ -65,8 +65,41 @@ async def get_resource(
|
||||||
# Get the resource if it's defined in user auth provider's resources (external)
|
# Get the resource if it's defined in user auth provider's resources (external)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
provider = providers[user.auth_provider_id]
|
provider = providers[user.auth_provider_id]
|
||||||
|
if ":" in resource_name:
|
||||||
|
# Third-party resource provider: send the request with the request token
|
||||||
|
resource_provider_id, resource_name = resource_name.split(":", 1)
|
||||||
|
provider = providers[user.auth_provider_id]
|
||||||
|
resource_provider: ResourceProvider = provider.get_resource_provider(
|
||||||
|
resource_provider_id
|
||||||
|
)
|
||||||
|
resource_url = resource_provider.get_resource_url(resource_name)
|
||||||
|
async with AsyncClient(verify=resource_provider.verify_ssl) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
resource_url,
|
||||||
|
headers={
|
||||||
|
"Content-type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"auth_provider": user.auth_provider_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR, err.__class__.__name__
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if resp.is_success:
|
||||||
|
return resp.json()
|
||||||
|
else:
|
||||||
|
reason_str: str
|
||||||
|
try:
|
||||||
|
reason_str = resp.json().get("detail", str(resp))
|
||||||
|
except Exception:
|
||||||
|
reason_str = str(resp.text)
|
||||||
|
raise HTTPException(resp.status_code, reason_str)
|
||||||
# Third party resource (provided through the auth provider)
|
# Third party resource (provided through the auth provider)
|
||||||
# The token is just passed on
|
# The token is just passed on
|
||||||
|
# XXX: is this branch valid anymore?
|
||||||
if resource_name in [r.resource_name for r in provider.resources]:
|
if resource_name in [r.resource_name for r in provider.resources]:
|
||||||
return await get_auth_provider_resource(
|
return await get_auth_provider_resource(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
|
@ -75,31 +108,31 @@ async def get_resource(
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
# Internal resource (provided here)
|
# Internal resource (provided here)
|
||||||
if resource_name in registry.resource_providers:
|
if resource_name in registry.resources:
|
||||||
resource_provider = registry.resource_providers[resource_name]
|
resource = registry.resources[resource_name]
|
||||||
reason: dict[str, str] = {}
|
reason: dict[str, str] = {}
|
||||||
if not resource_provider.is_public:
|
if not resource.is_public:
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Resource is not public")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Resource is not public")
|
||||||
else:
|
else:
|
||||||
if resource_provider.scope_required is not None and not user.has_scope(
|
if resource.scope_required is not None and not user.has_scope(
|
||||||
resource_provider.scope_required
|
resource.scope_required
|
||||||
):
|
):
|
||||||
reason["scope"] = (
|
reason["scope"] = (
|
||||||
f"No scope {resource_provider.scope_required} in the access token "
|
f"No scope {resource.scope_required} in the access token "
|
||||||
"but it is required for accessing this resource"
|
"but it is required for accessing this resource"
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
resource_provider.role_required is not None
|
resource.role_required is not None
|
||||||
and resource_provider.role_required not in user.roles_as_set
|
and resource.role_required not in user.roles_as_set
|
||||||
):
|
):
|
||||||
reason["role"] = (
|
reason["role"] = (
|
||||||
f"You don't have the role {resource_provider.role_required} "
|
f"You don't have the role {resource.role_required} "
|
||||||
"but it is required for accessing this resource"
|
"but it is required for accessing this resource"
|
||||||
)
|
)
|
||||||
if len(reason) == 0:
|
if len(reason) == 0:
|
||||||
try:
|
try:
|
||||||
resp = await resource_provider.process(user=user, resource_id=resource_id)
|
resp = await resource.process(user=user, resource_id=resource_id)
|
||||||
return resp
|
return resp
|
||||||
except ProcessError as err:
|
except ProcessError as err:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
@ -116,12 +149,11 @@ async def get_auth_provider_resource(
|
||||||
provider: Provider, resource_name: str, token: OAuth2Token | None, user: User
|
provider: Provider, resource_name: str, token: OAuth2Token | None, user: User
|
||||||
) -> ProcessResult:
|
) -> ProcessResult:
|
||||||
if token is None:
|
if token is None:
|
||||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"No auth token")
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No auth token")
|
||||||
access_token = token["access_token"]
|
access_token = token["access_token"]
|
||||||
resource = [r for r in provider.resources if r.resource_name == resource_name][0]
|
|
||||||
async with AsyncClient() as client:
|
async with AsyncClient() as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
url=provider.url + resource.url,
|
url=provider.get_resource_url(resource_name),
|
||||||
headers={
|
headers={
|
||||||
"Content-type": "application/json",
|
"Content-type": "application/json",
|
||||||
"Authorization": f"Bearer {access_token}",
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
|
|
@ -4,7 +4,7 @@ import random
|
||||||
from typing import Type, Tuple
|
from typing import Type, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel, computed_field, AnyUrl
|
from pydantic import AnyHttpUrl, BaseModel, computed_field, AnyUrl
|
||||||
from pydantic_settings import (
|
from pydantic_settings import (
|
||||||
BaseSettings,
|
BaseSettings,
|
||||||
SettingsConfigDict,
|
SettingsConfigDict,
|
||||||
|
@ -22,6 +22,22 @@ class Resource(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceProvider(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
base_url: AnyUrl
|
||||||
|
resources: list[Resource] = []
|
||||||
|
verify_ssl: bool = True
|
||||||
|
|
||||||
|
def get_resource(self, resource_name: str) -> Resource:
|
||||||
|
return [
|
||||||
|
resource for resource in self.resources if resource.resource_name == resource_name
|
||||||
|
][0]
|
||||||
|
|
||||||
|
def get_resource_url(self, resource_name: str) -> str:
|
||||||
|
return f"{self.base_url}{self.get_resource(resource_name).url}"
|
||||||
|
|
||||||
|
|
||||||
class AuthProviderSettings(BaseModel):
|
class AuthProviderSettings(BaseModel):
|
||||||
"""Auth provider, can also be a resource server"""
|
"""Auth provider, can also be a resource server"""
|
||||||
|
|
||||||
|
@ -45,6 +61,7 @@ class AuthProviderSettings(BaseModel):
|
||||||
session_key: str = "sid"
|
session_key: str = "sid"
|
||||||
skip_verify_signature: bool = True
|
skip_verify_signature: bool = True
|
||||||
disabled: bool = False
|
disabled: bool = False
|
||||||
|
resource_providers: list[ResourceProvider] = []
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
|
@ -67,13 +84,6 @@ class AuthProviderSettings(BaseModel):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ResourceProvider(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
base_url: AnyUrl
|
|
||||||
resources: list[Resource] = []
|
|
||||||
|
|
||||||
|
|
||||||
class AuthSettings(BaseModel):
|
class AuthSettings(BaseModel):
|
||||||
show_session_details: bool = False
|
show_session_details: bool = False
|
||||||
providers: list[AuthProviderSettings] = []
|
providers: list[AuthProviderSettings] = []
|
||||||
|
@ -92,13 +102,13 @@ class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
||||||
|
|
||||||
auth: AuthSettings = AuthSettings()
|
auth: AuthSettings = AuthSettings()
|
||||||
resource_providers: list[ResourceProvider] = []
|
|
||||||
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
|
secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16))
|
||||||
log: bool = False
|
log: bool = False
|
||||||
insecure: Insecure = Insecure()
|
insecure: Insecure = Insecure()
|
||||||
cors_origins: list[str] = []
|
cors_origins: list[str] = []
|
||||||
debug_token: bool = False
|
debug_token: bool = False
|
||||||
show_token: bool = False
|
show_token: bool = False
|
||||||
|
show_external_resource_providers_links: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def settings_customise_sources(
|
def settings_customise_sources(
|
||||||
|
|
|
@ -2,7 +2,9 @@ async function checkHref(elem, token, authProvider) {
|
||||||
const msg = document.getElementById("msg")
|
const msg = document.getElementById("msg")
|
||||||
const resourceName = elem.getAttribute("resource-name")
|
const resourceName = elem.getAttribute("resource-name")
|
||||||
const resourceId = elem.getAttribute("resource-id")
|
const resourceId = elem.getAttribute("resource-id")
|
||||||
const url = resourceId ? `resource/${resourceName}/${resourceId}` : `resource/${resourceName}`
|
const resourceProviderId = elem.getAttribute("resource-provider-id") ? elem.getAttribute("resource-provider-id") : ""
|
||||||
|
const fqResourceName = resourceProviderId ? `${resourceProviderId}:${resourceName}` : resourceName
|
||||||
|
const url = resourceId ? `resource/${fqResourceName}/${resourceId}` : `resource/${fqResourceName}`
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
|
@ -30,11 +32,13 @@ function checkPerms(className, token, authProvider) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get_resource(resource_name, token, authProvider, resource_id) {
|
async function get_resource(resourceName, token, authProvider, resourceId, resourceProviderId) {
|
||||||
|
// BaseUrl for an external resource provider
|
||||||
//if (!keycloak.keycloak) { return }
|
//if (!keycloak.keycloak) { return }
|
||||||
const msg = document.getElementById("msg")
|
const msg = document.getElementById("msg")
|
||||||
const resourceElem = document.getElementById('resource')
|
const resourceElem = document.getElementById('resource')
|
||||||
const url = resource_id ? `resource/${resource_name}/${resource_id}` : `resource/${resource_name}`
|
const fqResourceName = resourceProviderId ? `${resourceProviderId}:${resourceName}` : resourceName
|
||||||
|
const url = resourceId ? `resource/${fqResourceName}/${resourceId}` : `resource/${fqResourceName}`
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
|
|
|
@ -66,29 +66,50 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr>
|
<hr>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% if resource_providers %}
|
<!--
|
||||||
|
-->
|
||||||
|
{% if resources %}
|
||||||
<p>
|
<p>
|
||||||
{{ auth_provider.name }} provides these resources:
|
{{ auth_provider.name }} provides these resources:
|
||||||
</p>
|
</p>
|
||||||
<div class="links-to-check">
|
<div class="links-to-check">
|
||||||
{% for name, resource_provider in resource_providers.items() %}
|
{% for name, resource in resources.items() %}
|
||||||
{% if resource_provider.default_resource_id %}
|
{% if resource.default_resource_id %}
|
||||||
<button resource-name="{{ name }}"
|
<button resource-name="{{ name }}"
|
||||||
resource-id="{{ resource_provider.default_resource_id }}"
|
resource-id="{{ resource.default_resource_id }}"
|
||||||
onclick="get_resource('{{ name }}', '{{ access_token }}', '{{ auth_provider.id }}', '{{ resource_provider.default_resource_id }}')"
|
onclick="get_resource('{{ name }}', '{{ access_token }}', '{{ auth_provider.id }}', '{{ resource.default_resource_id }}')"
|
||||||
>
|
>
|
||||||
{{ resource_provider.name }}
|
{{ resource.name }}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button resource-name="{{ name }}"
|
<button resource-name="{{ name }}"
|
||||||
onclick="get_resource('{{ name }}', '{{ access_token }}', '{{ auth_provider.id }}')"
|
onclick="get_resource('{{ name }}', '{{ access_token }}', '{{ auth_provider.id }}')"
|
||||||
>
|
>
|
||||||
{{ resource_provider.name }}
|
{{ resource.name }}
|
||||||
</buttona>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if resource_providers %}
|
||||||
|
<p>{{ auth_provider.name }} can request resources from third party resource providers:</p>
|
||||||
|
{% for resource_provider in resource_providers %}
|
||||||
|
<div class="links-to-check">
|
||||||
|
{{ resource_provider.name }}
|
||||||
|
{% for resource in resource_provider.resources %}
|
||||||
|
<button resource-name="{{ resource.resource_name }}"
|
||||||
|
resource-id="{{ resource.default_resource_id }}"
|
||||||
|
resource-provider-id="{{ resource_provider.id }}"
|
||||||
|
onclick="get_resource('{{ resource.resource_name }}', '{{ access_token }}',
|
||||||
|
'{{ auth_provider.id }}', '{{ resource.default_resource_id }}',
|
||||||
|
'{{ resource_provider.id }}')"
|
||||||
|
>
|
||||||
|
{{ resource.name }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
<div class="resourceResult">
|
<div class="resourceResult">
|
||||||
<div id="resource" class="resource"></div>
|
<div id="resource" class="resource"></div>
|
||||||
<div id="msg" class="msg error"></div>
|
<div id="msg" class="msg error"></div>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue