Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,10 @@ CORS_ALLOWED_HOSTS="http://localhost:8081,http://localhost:8002"
TOTP_SECRET=12345678901234567890
TOTP_DIGEST=sha1
TOTP_RETURN_DIGITS=8
TOTP_TIME_STEP=30
TOTP_TIME_STEP=30
GOOGLE_OAUTH_ID=
GOOGLE_OAUTH_CLIENT=
GOOGLE_OAUTH_SECRET=
GOOGLE_OAUTH_REDIRECT_RESPONSE=https://127.0.0.1:8000/auth/google-response
GOOGLE_OAUTH_JS_ORIGINS="http://127.0.0.1:8000,http://localhost:8081"
GOOGLE_OAUTH_SCOPES="https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile,openid"
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,19 @@ __pycache__/
*py.cover

#Js
*node_modules
package-lock.json

#VSCode
*.vs
*.vscode

#Expo
.expo

#npm
*node_modules
dist/

# Visual Studio
*appsettings.Development.json
*.vspscc
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ It is a microservice for user administration and authenication, using JWT tokens
## Requirements
- Python 3.12+
- FastApi 0.124+
- Google account and activate [Google cloud](https://console.cloud.google.com) to obtain OAuth2 config values (Optional)

> [!IMPORTANT]
> It is necessary to complete the configuration file(.env), and create the PEM files and place them in the root folder
Expand Down Expand Up @@ -40,7 +41,7 @@ openssl rsa -in private_key.pem -pubout -out public_key.pem
```
5. Set configuration file (.env)

The microservice uses mongoDB as its database, so the connection string and other configurations (mongodb, JWT, CORS, logs) must be included
The microservice uses mongoDB as its database, so the connection string and other configurations (mongodb, JWT, CORS, logs, Google OAuth2) must be included

6. Run local development server
```bash
Expand All @@ -67,4 +68,4 @@ docker run -p 8000:80 --env-file .env auth-service:latest
```

> [!NOTE]
> Since the project is used for learning, it does not strictly follow the concept of microservices, where each microservice should have its own realm of responsability
> Since the project is used for learning, it does not strictly follow the concept of microservices, where each microservice should have its own realm of responsability and use different approaches.
53 changes: 41 additions & 12 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
pip
fastapi
uvicorn[standard]
pymongo[srv]
log2mongo
python-dotenv
python-multipart
pyjwt
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.0
bcrypt==4.0.1
passlib[bcrypt]
cryptography
dependency-injector
setuptools
certifi==2026.1.4
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.1
cryptography==46.0.3
dependency-injector==4.48.3
dnspython==2.8.0
fastapi==0.127.0
google-auth==2.48.0
google-auth-oauthlib==1.2.4
h11==0.16.0
httptools==0.7.1
idna==3.11
log2mongo==0.1.0
oauthlib==3.3.1
passlib==1.7.4
pyasn1==0.6.2
pyasn1_modules==0.4.2
pycparser==2.23
pydantic==2.12.5
pydantic_core==2.41.5
PyJWT==2.10.1
pymongo==4.15.5
python-dotenv==1.2.1
python-multipart==0.0.21
PyYAML==6.0.3
requests==2.32.5
requests-oauthlib==2.0.0
rsa==4.9.1
setuptools==80.9.0
starlette==0.50.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.40.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1
1 change: 1 addition & 0 deletions src/dependency_injection/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Container(containers.DeclarativeContainer):
"src.routers.users_router",
"src.routers.admin.users_router",
"src.routers.admin.security_router",
"src.routers.oauth2_router",
"src.services.user_service",
"src.services.login_service",
"src.services.jwt_service",
Expand Down
6 changes: 4 additions & 2 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from datetime import datetime
from fastapi import FastAPI
from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
import os

from src.routers import auth_router, products_router, users_router
from src.routers import auth_router, products_router, users_router, oauth2_router
from src.routers.admin import users_router as admin_user_router, security_router
from src.middlewares.jwt_middleware import JWTMiddleware
from src.middlewares.http_middleware import HttpMiddleware
Expand Down Expand Up @@ -42,8 +43,9 @@ async def shutdown():
app.include_router(products_router.router)
app.include_router(admin_user_router.router)
app.include_router(security_router.router)
app.include_router(oauth2_router.router)

#Root route
@app.get("/")
async def main():
return { "message": "Learning python" }
return datetime.now()
6 changes: 4 additions & 2 deletions src/models/address_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@

from src.models.pydantic_objects import PyObjectId

def set_id(value) -> ObjectId:
def set_id(value):
if not value:
return ObjectId()
if not isinstance(value, ObjectId):
return ObjectId(value)
return value


class Address(BaseModel):
id: Annotated[Optional[PyObjectId], PlainValidator(set_id), Field(validate_default=True)] = Field(default=None, validation_alias="_id")
id: Annotated[Optional[PyObjectId], PlainValidator(set_id), Field(validate_default=True, serialization_alias="_id")] = Field(default=None, validation_alias="_id")
country: str
state: str
colony: str
Expand Down
5 changes: 5 additions & 0 deletions src/models/totp_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from enum import Enum

class TOTPOptions(str, Enum):
ascii = "ascii"
hex = "hex"
1 change: 1 addition & 0 deletions src/models/user_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class User(BaseModel):
twofactor_enabled: bool = False
roles: Optional[List[str]] = list()
address: Optional[List[Address]] = list()
issuer: Optional[str] = None
online: bool = False
disabled: bool = False
created_at: datetime = datetime.now()
Expand Down
1 change: 1 addition & 0 deletions src/models/user_picture.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class UserPicture(BaseModel):
id: Optional[PyObjectId] = Field(default= None, serialization_alias="_id")
content_type: str | None = None
picture: Optional[bytes] = None
picture_url: Optional[str] = None
created_at: datetime = datetime.now()
updated_at: Optional[datetime] = None

Expand Down
8 changes: 5 additions & 3 deletions src/routers/admin/security_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from src.dependency_injection.containers import Container
from src.middlewares.auth_roles_jwt import JWTCustom
from src.models.totp_model import TOTPOptions
import src.services.totp_service as securitySvc

oauth2_scheme = JWTCustom(tokenUrl="/auth/sign-in")
Expand All @@ -13,10 +14,11 @@
prefix="/security")
totp_dependency = Annotated[securitySvc.TOTP, Depends(Provide[Container.totp])]

@router.get("/2fa-now")
@router.get("/2fa-now/{options}")
@inject
async def get_2f_code(totp: totp_dependency, secret: Optional[str] = None):
totp_code = await totp.now(secret)
async def get_2f_code(totp: totp_dependency, options: TOTPOptions, secret: Optional[str] = None):
is_ascii = True if options is TOTPOptions.ascii else False
totp_code = await totp.now(secret, is_ascii)
if totp_code:
return Response(content=totp_code, media_type="plain/text")
else:
Expand Down
75 changes: 45 additions & 30 deletions src/routers/admin/users_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from src.models.user_model import User
from src.models.address_model import Address
from src.services.user_service import change_password, insert_address
from src.services.user_service import change_password, get_address, insert_address
from src.dependencies import get_db
from src.middlewares.auth_roles_jwt import JWTCustom
from src.dependency_injection.containers import Container
Expand All @@ -22,37 +22,34 @@

@router.get("/user", response_model=User)
@inject
async def get_user(email: str, db: db_dependency, log: log_dependency):
async def get_user(email: str, db: db_dependency, log: log_dependency, response: Response) -> User | None:
try:
log.logger.info(f"get user: {email}")
user = await uSvc.get_user(email, db)
if user:
return Response(content=user.model_dump_json(), media_type="application/json")
else:
return Response(status_code=404)
return user
response.status_code = 404
except Exception as e:
log.logger.error(e)

@router.get("/users")
@inject
async def get_users(db: db_dependency, log: log_dependency, response: Response) -> list[User] | None:
try:
log.logger.info(f"get users: {db.name}")
users = await uSvc.get_users(db)
if users == None:
response.status_code = 404
return users
if users:
return users
response.status_code = 404
except Exception as e:
log.logger.error(e)

@router.post("/users")
@inject
async def create_user(model: User, db: db_dependency, log: log_dependency):
async def create_user(model: User, db: db_dependency, log: log_dependency, response: Response):
try:
user = await uSvc.create_user(db, model)
if user is not None:
if user:
return user

response.status_code = 400
except Exception as e:
log.logger.error(e)

Expand All @@ -62,45 +59,63 @@ async def update_user(model: User, db: db_dependency, log: log_dependency, respo
try:
result = await uSvc.update_user(db, model)
if result:
response.status_code = 404
response.status_code = 200
return
response.status_code = 400
except Exception as e:
log.logger.error(e)

@router.delete("/users")
@inject
async def delete_user(email: str, db: db_dependency, log: log_dependency):
async def delete_user(email: str, db: db_dependency, log: log_dependency, response: Response):
try:
result = await uSvc.deleted_user(db, email)
if result:
return True

if await uSvc.deleted_user(db, email):
response.status_code = 200
return
response.status_code = 400
except Exception as e:
log.logger.error(e)

@router.get("/change-password")
@router.post("/user/change-password")
@inject
async def update_password(email: str, password: str, db: db_dependency, log: log_dependency, response: Response):
try:
result = await change_password(db, email, password)
if not result:
response.status_code = 400
if await change_password(db, email, password):
response.status_code = 200
return
response.status_code = 400
except Exception as e:
log.logger.error(e)

@router.post("/users/address")
@inject
async def create_address(email: str, address: Address, db: db_dependency, log: log_dependency, response: Response) -> Address | None:
try:
new_address = await insert_address(email, address, db)
if new_address:
return new_address
response.status_code = 400
except Exception as e:
log.logger.error(e)

@router.post("/users/address", response_model_by_alias = False)
@router.get("/user/address")
@inject
async def create_address(email: str, address: Address, db: db_dependency, log: log_dependency):
async def get_addresses(email: str, db: db_dependency, log: log_dependency, response : Response) -> list[Address] | None:
try:
result = await insert_address(db, email, address)
return result
addresses = await get_address(db, email)
if addresses:
return addresses
response.status_code = 404
except Exception as e:
log.logger.error(e)

@router.put("/user/address")
@inject
async def update_address(email: str, address: Address, db: db_dependency, log: log_dependency):
async def update_address(email: str, address: Address, db: db_dependency, log: log_dependency, response: Response):
try:
result = await uSvc.update_address(db, email, address)
return result
if await uSvc.update_address(db, email, address):
response.status_code = 200
return
response.status_code = 400
except Exception as e:
log.logger.error(e)
18 changes: 12 additions & 6 deletions src/routers/auth_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
router = APIRouter(
tags=["auth"],
prefix="/auth"
)
)
oauth2_scheme = JWTCustom(tokenUrl="/auth/sign-in")
db_dependency = Annotated[MongoAsyncService, Depends(Provide[Container.database_client])]
log_dependency = Annotated[log2mongo, Depends(Provide[Container.logging])]
Expand All @@ -29,8 +29,14 @@ async def sign_up(model: SignUp, db: db_dependency, log: log_dependency, request
log.logger.info(f"{ model.email } sign-up from ip: { client_host }")
content = ""
status_code = 0
user = await create_user(db.database, User(name= model.name, email= model.email, password= model.password, disabled=False))
if user is not None:
user = await create_user(db.database, User(
name = model.name,
email = model.email,
password = model.password,
issuer = '',
disabled = False)
)
if user:
content = user.model_dump_json()
status_code = status.HTTP_200_OK
else:
Expand All @@ -51,7 +57,7 @@ async def sign_in(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db
content = None
status_code = status.HTTP_401_UNAUTHORIZED
result, token = await login(form_data.username, form_data.password, db.database)
if token is not None:
if token:
content = token.model_dump()
status_code = status.HTTP_200_OK
elif result and token is None:
Expand All @@ -67,9 +73,9 @@ async def sign_in(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db
async def validate_token(log: log_dependency, email: Annotated[str, Depends(oauth2_scheme)]):
try:
status_code = status.HTTP_401_UNAUTHORIZED
if email is not None:
if email:
status_code = status.HTTP_200_OK
except Exception as e:
log.logger.error(e)
finally:
return Response(email, status_code, media_type="text/plain")
return Response(email, status_code, media_type = "text/plain")
Loading
Loading