Skip to content

Commit 7ec6833

Browse files
committed
code refactoring and document update
1 parent 6a6adfa commit 7ec6833

File tree

10 files changed

+197
-24
lines changed

10 files changed

+197
-24
lines changed

docs/security/authentication.md

Lines changed: 146 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ class AuthGuard(GuardHttpBearerAuth):
293293
) -> t.Optional[t.Any]:
294294
try:
295295
data = await self.jwt_service.decode_async(credentials.credentials)
296-
return UserIdentity(auth_type="bearer", **data)
296+
return UserIdentity(auth_type=self.scheme, **data)
297297
except Exception as ex:
298298
logger.logger.error(f"[AuthGuard] Exception: {ex}")
299299
self.raise_exception()
@@ -500,12 +500,11 @@ Let us define a mechanism for declaring routes as anonymous or public.
500500
from ellar.common.serializer.guard import (
501501
HTTPAuthorizationCredentials,
502502
)
503-
from ellar.common import IExecutionContext, set_metadata
503+
from ellar.common import IExecutionContext, set_metadata, logger
504504
from ellar.core.guards import GuardHttpBearerAuth
505505
from ellar.core import Reflector
506506
from ellar.di import injectable
507507
from ellar_jwt import JWTService
508-
from ellar.common import logger, IExecutionContext
509508

510509
IS_ANONYMOUS = 'is_anonymous'
511510

@@ -532,7 +531,7 @@ Let us define a mechanism for declaring routes as anonymous or public.
532531
533532
try:
534533
data = await self.jwt_service.decode_async(credentials.credentials)
535-
return UserIdentity(auth_type="bearer", **data)
534+
return UserIdentity(auth_type=self.scheme, **data)
536535
except Exception as ex:
537536
logger.error(f"[AuthGuard] Exception: {ex}")
538537
self.raise_exception()
@@ -550,13 +549,10 @@ Let us define a mechanism for declaring routes as anonymous or public.
550549
from ellar.common.serializer.guard import (
551550
HTTPAuthorizationCredentials,
552551
)
553-
from ellar.common import IExecutionContext, set_metadata, constants, GuardCanActivate
552+
from ellar.common import IExecutionContext, set_metadata, constants, GuardCanActivate, logger
554553
from ellar.core.guards import GuardHttpBearerAuth
555-
from ellar.core import Reflector
556554
from ellar.di import injectable
557555
from ellar_jwt import JWTService
558-
from ellar.common.logger import logger
559-
from ellar.common import logger, IExecutionContext
560556

561557

562558
def allow_any() -> t.Callable:
@@ -619,3 +615,145 @@ class AuthController(ControllerBase):
619615
```
620616

621617
## **2. Authentication Schemes**
618+
619+
Authentication scheme is another strategy for identifying the user who is using the application. The difference between it and
620+
and Guard strategy is your identification executed at middleware layer when processing incoming request while guard execution
621+
happens just before route function is executed.
622+
623+
Ellar provides `BaseAuthenticationHandler` contract which defines what is required to set up any authentication strategy.
624+
We are going to make some modifications on the existing project to see how we can achieve the same result and to show how authentication handlers in ellar.
625+
626+
### Creating a JWT Authentication Handler
627+
Just like AuthGuard, we need to create its equivalent. But first we need to create a `auth_scheme.py` at the root level
628+
of your application for us to define a `JWTAuthentication` handler.
629+
630+
631+
```python title='prject_name.auth_scheme.py' linenums='1'
632+
import typing as t
633+
from ellar.common.serializer.guard import (
634+
HTTPAuthorizationCredentials,
635+
)
636+
from ellar.auth import UserIdentity
637+
from ellar.auth.handlers import HttpBearerAuthenticationHandler
638+
from ellar.common import IHostContext
639+
from ellar.di import injectable
640+
from ellar_jwt import JWTService
641+
642+
from starlette.exceptions import HTTPException
643+
644+
645+
@injectable
646+
class JWTAuthentication(HttpBearerAuthenticationHandler):
647+
def __init__(self, jwt_service: JWTService) -> None:
648+
self.jwt_service = jwt_service
649+
650+
async def authentication_handler(
651+
self,
652+
context: IHostContext,
653+
credentials: HTTPAuthorizationCredentials,
654+
) -> t.Optional[t.Any]:
655+
# this function will be called by Identity Middleware but only when a `Bearer token` is found on the header request
656+
try:
657+
data = await self.jwt_service.decode_async(credentials.credentials)
658+
return UserIdentity(auth_type=self.scheme, **data)
659+
except Exception as ex:
660+
# if we cant identity the user or token has expired we raise 401 error.
661+
raise HTTPException(status_code=401) from ex
662+
```
663+
664+
Let us make `JWTAuthentication` Handler available for ellar to use as shown below
665+
666+
```python title='server.py' linenums='1'
667+
import os
668+
from ellar.common.constants import ELLAR_CONFIG_MODULE
669+
from ellar.core.factory import AppFactory
670+
from .root_module import ApplicationModule
671+
from .auth_scheme import JWTAuthentication
672+
673+
application = AppFactory.create_from_app_module(
674+
ApplicationModule,
675+
config_module=os.environ.get(
676+
ELLAR_CONFIG_MODULE, "project_name.config:DevelopmentConfig"
677+
),
678+
)
679+
application.add_authentication_schemes(JWTAuthentication)
680+
681+
```
682+
Unlike guards, Authentication handlers are registered global by default as shown in the above illustration.
683+
Also, we need to remove `GlobalGuard` registration we did in `AuthModule`, so that we dont have too user identification checks.
684+
685+
!!!note
686+
In the above illustration, we added JWTAuthentication as a type. This means JWTAuthentication instance will be created by DI. We can using this method because we want to inject `JWTService`.
687+
But if you don't have any need for DI injection, you can use the below.
688+
```python
689+
...
690+
application.add_authentication_schemes(JWTAuthentication())
691+
```
692+
693+
Next, we register a simple guard `AuthenticationRequiredGuard` globally to the application. `AuthenticationRequiredGuard` is a simply guard
694+
that checks if a request has a valid user identity.
695+
696+
```python title='server.py' linenums='1'
697+
import os
698+
from ellar.common.constants import ELLAR_CONFIG_MODULE
699+
from ellar.core.factory import AppFactory
700+
from ellar.auth.guard import AuthenticatedRequiredGuard
701+
from .root_module import ApplicationModule
702+
from .auth_scheme import JWTAuthentication
703+
704+
705+
application = AppFactory.create_from_app_module(
706+
ApplicationModule,
707+
config_module=os.environ.get(
708+
ELLAR_CONFIG_MODULE, "project_name.config:DevelopmentConfig"
709+
),
710+
global_guards=[AuthenticatedRequiredGuard]
711+
)
712+
application.add_authentication_schemes(JWTAuthentication)
713+
```
714+
We need to refactor auth controller and mark refresh and sign_in function as public routes
715+
```python
716+
from ellar.common import Controller, ControllerBase, post, Body, get
717+
from ellar.auth import SkipAuth
718+
from ellar.openapi import ApiTags
719+
from .services import AuthService
720+
721+
722+
@Controller
723+
@ApiTags(name='Authentication', description='User Authentication Endpoints')
724+
class AuthController(ControllerBase):
725+
def __init__(self, auth_service: AuthService) -> None:
726+
self.auth_service = auth_service
727+
728+
@post("/login")
729+
@SkipAuth()
730+
async def sign_in(self, username: Body[str], password: Body[str]):
731+
return await self.auth_service.sign_in(username=username, password=password)
732+
733+
@get("/profile")
734+
async def get_profile(self):
735+
return self.context.user
736+
737+
@SkipAuth()
738+
@post("/refresh")
739+
async def refresh_token(self, payload: str = Body(embed=True)):
740+
return await self.auth_service.refresh_token(payload)
741+
742+
743+
```
744+
Still having the server running, we can test as before
745+
746+
```shell
747+
$ # GET /auth/profile
748+
$ curl http://localhost:8000/auth/profile
749+
{"detail":"Forbidden"} # status_code=403
750+
751+
$ # POST /auth/login
752+
$ curl -X POST http://localhost:8000/auth/login -d '{"username": "john", "password": "password"}' -H "Content-Type: application/json"
753+
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTg3OTE0OTE..."}
754+
755+
$ # GET /profile using access_token returned from previous step as bearer code
756+
$ curl http://localhost:8000/auth/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
757+
{"exp":1698793558,"iat":1698793258,"jti":"e96e94c5c3ef4fbbbd7c2468eb64534b","sub":1,"user_id":1,"username":"john", "id":null,"auth_type":"bearer"}
758+
759+
```

ellar/auth/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .decorators import AuthenticationRequired, Authorize, CheckPolicies
1+
from .decorators import AuthenticationRequired, Authorize, CheckPolicies, SkipAuth
22
from .handlers import BaseAuthenticationHandler
33
from .identity import UserIdentity
44
from .interceptor import AuthorizationInterceptor
@@ -26,4 +26,5 @@
2626
"AppIdentitySchemes",
2727
"IdentityAuthenticationService",
2828
"AuthenticationRequired",
29+
"SkipAuth",
2930
]

ellar/auth/decorators.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import typing as t
22

3+
from ellar.common import constants
34
from ellar.common import set_metadata as set_meta
4-
from ellar.common.constants import GUARDS_KEY, ROUTE_INTERCEPTORS
55

66
from .constants import POLICY_KEYS
77
from .guard import AuthenticatedRequiredGuard
@@ -31,7 +31,7 @@ def Authorize() -> t.Callable:
3131
:return:
3232
"""
3333

34-
return set_meta(ROUTE_INTERCEPTORS, [AuthorizationInterceptor])
34+
return set_meta(constants.ROUTE_INTERCEPTORS, [AuthorizationInterceptor])
3535

3636

3737
def AuthenticationRequired(
@@ -48,11 +48,24 @@ def AuthenticationRequired(
4848
@return: Callable
4949
"""
5050
if callable(authentication_scheme):
51-
return set_meta(GUARDS_KEY, [AuthenticatedRequiredGuard(None, [])])(
51+
return set_meta(constants.GUARDS_KEY, [AuthenticatedRequiredGuard(None, [])])(
5252
authentication_scheme
5353
)
5454

5555
return set_meta(
56-
GUARDS_KEY,
56+
constants.GUARDS_KEY,
5757
[AuthenticatedRequiredGuard(authentication_scheme, openapi_scope or [])],
5858
)
59+
60+
61+
def SkipAuth() -> t.Callable:
62+
"""
63+
========= CONTROLLER AND ROUTE FUNCTION DECORATOR ==============
64+
Decorates a Class or Route Function with SKIP_AUTH attribute that is checked by `AuthenticationRequiredGuard`
65+
@return: Callable
66+
"""
67+
68+
return set_meta(
69+
constants.SKIP_AUTH,
70+
True,
71+
)

ellar/auth/guard.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import typing as t
22

33
import starlette.status
4-
from ellar.common import GuardCanActivate, IExecutionContext
4+
from ellar.common import GuardCanActivate, IExecutionContext, constants
5+
from ellar.core import Reflector
56

67

78
class AuthenticatedRequiredGuard(GuardCanActivate):
@@ -12,6 +13,7 @@ def __init__(
1213
) -> None:
1314
self.authentication_scheme = authentication_scheme
1415
self.openapi_scope = openapi_scope or []
16+
self.reflector = Reflector()
1517

1618
def openapi_security_scheme(self) -> t.Dict:
1719
# this will only add security scope to the applied controller or route function
@@ -21,4 +23,10 @@ def openapi_security_scheme(self) -> t.Dict:
2123
return {}
2224

2325
async def can_activate(self, context: IExecutionContext) -> bool:
26+
skip_auth = self.reflector.get_all_and_override(
27+
constants.SKIP_AUTH, context.get_handler(), context.get_class()
28+
)
29+
30+
if skip_auth:
31+
return True
2432
return context.user.is_authenticated

ellar/auth/handlers/schemes/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def openapi_security_scheme(cls) -> t.Dict:
109109

110110

111111
class BaseHttpAuth(BaseAuth, ABC):
112-
openapi_scheme: t.Optional[str] = None
112+
scheme: t.Optional[str] = None
113113
realm: t.Optional[str] = None
114114

115115
@classmethod
@@ -144,13 +144,13 @@ def _get_credentials(
144144

145145
@classmethod
146146
def openapi_security_scheme(cls) -> t.Dict:
147-
assert cls.openapi_scheme, "openapi_scheme is required"
147+
assert cls.scheme, "openapi_scheme is required"
148148
return {
149149
cls.openapi_name
150150
or cls.__name__: {
151151
"type": "http",
152152
"description": cls.openapi_description,
153-
"scheme": cls.openapi_scheme,
153+
"scheme": cls.scheme,
154154
"name": cls.openapi_name or cls.__name__,
155155
}
156156
}

ellar/auth/handlers/schemes/http.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
class HttpBearerAuth(BaseHttpAuth, ABC):
1919
exception_class = APIException
20-
openapi_scheme: str = "bearer"
20+
scheme: str = "bearer"
2121
openapi_bearer_format: t.Optional[str] = None
2222
header: str = "Authorization"
2323

@@ -36,7 +36,7 @@ def _get_credentials(
3636
scheme, _, credentials = self._authorization_partitioning(authorization)
3737
if not (authorization and scheme and credentials):
3838
return self.handle_invalid_request() # type: ignore[no-any-return]
39-
if scheme and str(scheme).lower() != self.openapi_scheme:
39+
if scheme and str(scheme).lower() != self.scheme:
4040
raise self.exception_class(
4141
status_code=self.status_code,
4242
detail="Invalid authentication credentials",
@@ -46,7 +46,7 @@ def _get_credentials(
4646

4747
class HttpBasicAuth(BaseHttpAuth, ABC):
4848
exception_class = APIException
49-
openapi_scheme: str = "basic"
49+
scheme: str = "basic"
5050
realm: t.Optional[str] = None
5151
header = "Authorization"
5252

@@ -75,7 +75,7 @@ def _get_credentials(self, connection: "HTTPConnection") -> HTTPBasicCredentials
7575

7676
if (
7777
not (authorization and scheme and credentials)
78-
or scheme.lower() != self.openapi_scheme
78+
or scheme.lower() != self.scheme
7979
):
8080
return self.handle_invalid_request() # type: ignore[no-any-return]
8181

@@ -95,5 +95,5 @@ def _get_credentials(self, connection: "HTTPConnection") -> HTTPBasicCredentials
9595

9696

9797
class HttpDigestAuth(HttpBearerAuth, ABC):
98-
openapi_scheme = "digest"
98+
scheme = "digest"
9999
header = "Authorization"

ellar/common/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060

6161
CALLABLE_COMMAND_INFO = "__CALLABLE_COMMAND_INFO__"
6262
GROUP_METADATA = "GROUP_METADATA"
63+
SKIP_AUTH = "SKIP_AUTH"
6364

6465

6566
class MODULE_METADATA(metaclass=AnnotationToValue):

ellar/openapi/module.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
)
1212
from ellar.core import DynamicModule, ModuleBase
1313
from ellar.di import injectable
14-
from ellar.openapi.docs_ui import IDocumentationUIContext
1514
from ellar.openapi.constants import OPENAPI_OPERATION_KEY
15+
from ellar.openapi.docs_ui import IDocumentationUIContext
1616
from ellar.openapi.openapi_v3 import OpenAPI
1717

1818
__all__ = ["OpenAPIDocumentModule"]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import subprocess
2+
3+
import pytest
4+
5+
6+
@pytest.fixture(scope="session")
7+
def install_ellar_jwt():
8+
try:
9+
import ellar_jwt # noqa
10+
except ImportError:
11+
subprocess.Popen(["pip", "install", "ellar_jwt"])
12+
yield

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[pytest]
2-
addopts = --strict-config --strict-markers --ignore=examples/03-auth-with-guards
2+
addopts = --strict-config --strict-markers
33
xfail_strict = true
44
junit_family = "xunit2"
55

0 commit comments

Comments
 (0)