Skip to content

Commit

Permalink
TACS-28 JWT API (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasanchez authored Apr 23, 2023
2 parents f79e4c3 + a4de629 commit af08af4
Show file tree
Hide file tree
Showing 11 changed files with 556 additions and 1 deletion.
28 changes: 28 additions & 0 deletions auth/src/auth/app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from fastapi import Depends

from auth.adapters.repository import InMemoryUserRepository, UserRepository
from auth.service_layer.auth import AuthService
from auth.service_layer.jwt import JwtService
from auth.service_layer.password_encoder import BcryptPasswordEncoder, PasswordEncoder
from auth.service_layer.register import RegisterService

Expand Down Expand Up @@ -44,3 +46,29 @@ def get_register_service(user_repository: UserRepositoryDependency,


RegisterDependency = Annotated[RegisterService, Depends(get_register_service)]


def get_jwt_service() -> JwtService:
"""
Dependency that returns a JwtService instance.
"""
return JwtService()


JwtServiceDependency = Annotated[JwtService, Depends(get_jwt_service)]


def get_auth_service(user_repository: UserRepositoryDependency,
jwt_service: JwtServiceDependency,
encoder: PasswordEncoderDependency) -> AuthService:
"""
Dependency that returns a RegisterService instance.
"""
return AuthService(
user_repository=user_repository,
encoder=encoder,
jwt_service=jwt_service,
)


AuthDependency = Annotated[AuthService, Depends(get_auth_service)]
3 changes: 2 additions & 1 deletion auth/src/auth/app/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi import APIRouter

from auth.entrypoints import actuator
from auth.entrypoints.v1 import users
from auth.entrypoints.v1 import auth, users

root_router = APIRouter()
api_router_v1 = APIRouter(prefix="/api/v1", tags=["v1"])
Expand All @@ -17,3 +17,4 @@

# API Routers
api_router_v1.include_router(users.router)
api_router_v1.include_router(auth.router)
24 changes: 24 additions & 0 deletions auth/src/auth/domain/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,27 @@ def username_alphanumeric(cls, v):
if not v.isalnum():
raise ValueError('must be alphanumeric')
return v.strip()


class AuthenticateUser(CamelCaseModel):
"""
Command that represents the intent to authenticate a user.
"""
username: str = Field(description="The user username.", example="johndoe")
password: str = Field(title="Password", description="Login Credential", min_length=min_length)

@validator('username')
def username_alphanumeric(cls, v):
if not v.isalnum():
raise ValueError('must be alphanumeric')
return v.strip().lower()


class AuthorizeToken(CamelCaseModel):
"""
Command that represents the intent to authorize a token.
"""
token: str = Field(description="A JSON Web Token",
example="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG5kb"
"2VAZ21haWwuY29tIiwicm9sZSI6IlVTRVIiLCJleHAiOjE2MjUwMzg4MjB9.5Y2QJ7kx1yD6Bh0jzH2QX9Y8cZJ"
"6vZl6YpKj1Z8JUWU")
25 changes: 25 additions & 0 deletions auth/src/auth/domain/events.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
Events that may occur in the application.
"""
import datetime

from pydantic import EmailStr, Field

from auth.domain.models import Role
Expand Down Expand Up @@ -28,3 +30,26 @@ class UserRegistered(CamelCaseModel):
profile_picture: str | None = Field(title="Profile Picture URL", description="The user profile picture.",
example="https://example.com/profile.jpg")
is_active: bool | None = Field(title="Is Active", description="The user is active.", example=True)


class UserAuthenticated(CamelCaseModel):
"""
Event that occurs when a user is authenticated.
"""
id: str = Field(description="The user id.", example="1")
username: str = Field(description="The user username.", example="johndoe")
email: EmailStr = Field(description="The user email.", example="john@doe.mail")
role: Role = Field(description="The user role.", example=Role.USER)
exp: datetime.datetime | None = Field(description="The expiration date of the token.",
example="2021-01-01T00:00:00Z")


class TokenGenerated(CamelCaseModel):
"""
Event that occurs when a token is generated.
"""
token: str = Field(description="A JSON Web Token",
example="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImpvaG5kb2UiLCJlbWFpbCI6ImpvaG5kb"
"2VAZ21haWwuY29tIiwicm9sZSI6IlVTRVIiLCJleHAiOjE2MjUwMzg4MjB9.5Y2QJ7kx1yD6Bh0jzH2QX9Y8cZJ"
"6vZl6YpKj1Z8JUWU")
type: str = Field(description="Token Type", example="Bearer", default="Bearer")
41 changes: 41 additions & 0 deletions auth/src/auth/entrypoints/v1/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Authentication & Authorization Entry Points
"""
from fastapi import APIRouter, HTTPException
from starlette.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED

from auth.app.dependencies import AuthDependency
from auth.domain.commands import AuthenticateUser, AuthorizeToken
from auth.domain.events import TokenGenerated, UserAuthenticated
from auth.domain.schemas import ResponseModel
from auth.service_layer.errors import InvalidCredentialsError

router = APIRouter(prefix='/auth', tags=['Auth'])


@router.post('/token', status_code=HTTP_200_OK, tags=["Commands"])
async def log_in(command: AuthenticateUser, auth_service: AuthDependency) -> ResponseModel[TokenGenerated]:
"""
Authenticates a user, providing an Access Token.
"""
try:
token = auth_service.authenticate(username=command.username, password=command.password)
except InvalidCredentialsError as e:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=str(e))

return ResponseModel(data=TokenGenerated(token=token))


@router.post('/user', status_code=HTTP_200_OK, tags=["Commands"])
async def authorize_token(
command: AuthorizeToken,
auth_service: AuthDependency) -> ResponseModel[UserAuthenticated]:
"""
Authorizes a token, providing the user's information.
"""
try:
user = auth_service.authorize(command.token)
except InvalidCredentialsError as e:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=str(e))

return ResponseModel(data=user)
80 changes: 80 additions & 0 deletions auth/src/auth/service_layer/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Auth Service
Authenticates and authorizes users.
"""
from auth.adapters.repository import Repository
from auth.domain.events import UserAuthenticated
from auth.service_layer.errors import InvalidCredentialsError
from auth.service_layer.jwt import JwtService
from auth.service_layer.password_encoder import PasswordEncoder


class AuthService:
"""
Authenticates and authorizes users.
"""

def __init__(self, user_repository: Repository,
encoder: PasswordEncoder,
jwt_service: JwtService):
self.user_repository = user_repository
self.encoder = encoder
self.jwt_service = jwt_service

def _verify_credentials(self, username: str, password: str | None = None) -> UserAuthenticated:
"""
Verifies if the user exists.
Args:
username: An unique username of the user.
password: The password to verify.
Returns:
The user authenticated.
Raises:
InvalidCredentialsError: If the user does not exist or the password is invalid.
"""
user = self.user_repository.find_by(username=username)

if not user:
raise InvalidCredentialsError(f"User {username} not found.")

if password:
self.encoder.verify(password, user.password)

return UserAuthenticated(**user.dict())

def authenticate(self, username: str, password: str) -> str:
"""
Authenticates a user.
Args:
username: An unique username of the user.
password: The password to verify.
Returns:
A Json Web Token.
Raises:
InvalidCredentialsError: If the user does not exist or the password is invalid.
"""
user = self._verify_credentials(username, password)

return self.jwt_service.get_token(user)

def authorize(self, token: str) -> UserAuthenticated:
"""
Authorizes a user.
Args:
token: The token to verify.
Raises:
InvalidCredentialsError: If the token is invalid.
"""
user_token = self.jwt_service.decode_user_token(token)

user = self._verify_credentials(user_token.username)

return user
84 changes: 84 additions & 0 deletions auth/src/auth/service_layer/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""JWT Service
"""
from datetime import datetime, timedelta

from jose import jwt

from auth.domain.events import UserAuthenticated
from auth.service_layer.errors import InvalidCredentialsError


class JwtService:
"""
JWT Service
Handles the creation and decoding of JWT tokens.
"""

def __init__(self,
secret_key: str = "25504cafb5843158167e6b423efab76f472d94f1b276eec960b9df1cb8a90569",
algorithm: str = "HS256",
token_expiration_minutes: int = 60):
self.secret_key = secret_key
self.algorithm = algorithm
self.token_expiration_minutes = token_expiration_minutes

def get_token(self, event: UserAuthenticated) -> str:
"""
Generates a JWT token for the user.
Args:
event: Credentials of the user.
Returns:
str: A Json Web Token.
Raises:
JwtError: If the token could not be encoded.
"""
access_token_expires = timedelta(minutes=self.token_expiration_minutes)

return self.create_token(payload=event.dict(), expires_delta=access_token_expires)

def create_token(self, payload: dict, expires_delta: timedelta) -> str:
"""
Encodes the payload into a JWT token.
Args:
payload:
expires_delta:
Returns:
str: A JWT token.
Raises:
JwtError: If the token could not be encoded.
"""
to_encode = payload.copy()
expire = datetime.utcnow() + expires_delta
to_encode.update({"exp": expire})

return jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)

def decode_user_token(self, token: str) -> UserAuthenticated:
"""
Decodes a JWT token.
Args:
token: A JWT token.
Returns:
dict: The decoded token.
Raises:
InvalidCredentialsError: If there's an error decoding the token.
"""
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])

user_token = UserAuthenticated(**payload)

return user_token
except jwt.JWTError as e:
raise InvalidCredentialsError(e) from e
35 changes: 35 additions & 0 deletions auth/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from auth.adapters.repository import InMemoryUserRepository, UserRepository
from auth.main import app
from auth.service_layer.auth import AuthService
from auth.service_layer.jwt import JwtService
from auth.service_layer.password_encoder import BcryptPasswordEncoder, PasswordEncoder
from auth.service_layer.register import RegisterService

Expand Down Expand Up @@ -89,3 +91,36 @@ def fixture_register_service(
RegisterService: A register service.
"""
return RegisterService(user_repository=user_repository, password_encoder=password_encoder)


@pytest.fixture(name="jwt_service")
def fixture_jwt_service() -> JwtService:
"""
Create a JWT service.
Returns:
JwtService: A JWT service.
"""
return JwtService()


@pytest.fixture(name="auth_service")
def fixture_auth_service(
user_repository: UserRepository, password_encoder: PasswordEncoder, jwt_service: JwtService
) -> AuthService:
"""
Create an authentication service.
Args:
user_repository (UserRepository): A user repository.
password_encoder (PasswordEncoder): A password encoder.
jwt_service (JwtService): A JWT service.
Returns:
AuthService: An authentication service.
"""
return AuthService(
user_repository=user_repository,
encoder=password_encoder,
jwt_service=jwt_service,
)
Loading

0 comments on commit af08af4

Please sign in to comment.