Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: User Login & Authorization #170

Merged
merged 2 commits into from
Nov 17, 2023
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
35 changes: 26 additions & 9 deletions API/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from osm_login_python.core import Auth
from pydantic import BaseModel, Field

from src.config import ADMIN_IDS, get_oauth_credentials
from src.app import Users
from src.config import get_oauth_credentials


class UserRole(Enum):
Expand All @@ -23,31 +24,47 @@ class AuthUser(BaseModel):

osm_auth = Auth(*get_oauth_credentials())

auth = Users()

def is_admin(osm_id: int):
admin_ids = [int(admin_id) for admin_id in ADMIN_IDS]
return osm_id in admin_ids

def get_user_from_db(osm_id: int):
user = auth.read_user(osm_id)
return user


def login_required(access_token: str = Header(...)):
user = AuthUser(**osm_auth.deserialize_access_token(access_token))
if is_admin(user.id):
user.role = UserRole.ADMIN
db_user = get_user_from_db(user.id)
user.role = db_user["role"]
return user


def get_optional_user(access_token: str = Header(default=None)) -> AuthUser:
if access_token:
user = AuthUser(**osm_auth.deserialize_access_token(access_token))
if is_admin(user.id):
user.role = UserRole.ADMIN
db_user = get_user_from_db(user.id)
user.role = db_user["role"]
return user
else:
# If no token provided, return a user with limited options or guest user
return AuthUser(id=0, username="guest", img_url=None)


def admin_required(user: AuthUser = Depends(login_required)):
if not is_admin(user.id):
db_user = get_user_from_db(user.id)
print(db_user)
if not db_user["role"] is UserRole.ADMIN.value:
raise HTTPException(status_code=403, detail="User is not an admin")
return user


def staff_required(user: AuthUser = Depends(login_required)):
db_user = get_user_from_db(user.id)

# admin is staff too
if not (
db_user["role"] is UserRole.STAFF.value
or db_user["role"] is UserRole.ADMIN.value
):
raise HTTPException(status_code=403, detail="User is not a staff")
return user
108 changes: 106 additions & 2 deletions API/auth/routers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import json

from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel

from . import AuthUser, admin_required, login_required, osm_auth
from src.app import Users

router = APIRouter(prefix="/auth")
from . import AuthUser, admin_required, login_required, osm_auth, staff_required

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


@router.get("/login/")
Expand Down Expand Up @@ -49,3 +52,104 @@ def my_data(user_data: AuthUser = Depends(login_required)):
Returns: user_data
"""
return user_data


class User(BaseModel):
osm_id: int
role: int


auth = Users()


# Create user
@router.post("/users/", response_model=dict)
async def create_user(params: User, user_data: AuthUser = Depends(admin_required)):
"""
Creates a new user and returns the user's information.

Args:
- params (User): The user data including osm_id and role.

Returns:
- Dict[str, Any]: A dictionary containing the osm_id of the newly created user.

Raises:
- HTTPException: If the user creation fails.
"""
return auth.create_user(params.osm_id, params.role)


# Read user by osm_id
@router.get("/users/{osm_id}", response_model=dict)
async def read_user(osm_id: int, user_data: AuthUser = Depends(staff_required)):
"""
Retrieves user information based on the given osm_id.

Args:
- osm_id (int): The OSM ID of the user to retrieve.

Returns:
- Dict[str, Any]: A dictionary containing user information.

Raises:
- HTTPException: If the user with the given osm_id is not found.
"""
return auth.read_user(osm_id)


# Update user by osm_id
@router.put("/users/{osm_id}", response_model=dict)
async def update_user(
osm_id: int, update_data: User, user_data: AuthUser = Depends(admin_required)
):
"""
Updates user information based on the given osm_id.

Args:
- osm_id (int): The OSM ID of the user to update.
- update_data (User): The data to update for the user.

Returns:
- Dict[str, Any]: A dictionary containing the updated user information.

Raises:
- HTTPException: If the user with the given osm_id is not found.
"""
return auth.update_user(osm_id, update_data)


# Delete user by osm_id
@router.delete("/users/{osm_id}", response_model=dict)
async def delete_user(osm_id: int, user_data: AuthUser = Depends(admin_required)):
"""
Deletes a user based on the given osm_id.

Args:
- osm_id (int): The OSM ID of the user to delete.

Returns:
- Dict[str, Any]: A dictionary containing the deleted user information.

Raises:
- HTTPException: If the user with the given osm_id is not found.
"""
return auth.delete_user(osm_id)


# Get all users
@router.get("/users/", response_model=list)
async def read_users(
skip: int = 0, limit: int = 10, user_data: AuthUser = Depends(staff_required)
):
"""
Retrieves a list of users with optional pagination.

Args:
- skip (int): The number of users to skip (for pagination).
- limit (int): The maximum number of users to retrieve (for pagination).

Returns:
- List[Dict[str, Any]]: A list of dictionaries containing user information.
"""
return auth.read_users(skip, limit)
62 changes: 12 additions & 50 deletions API/raw_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from .api_worker import process_raw_data
from .auth import AuthUser, UserRole, get_optional_user

router = APIRouter(prefix="")
router = APIRouter(prefix="", tags=["Extract"])


@router.get("/status/", response_model=StatusResponse)
Expand All @@ -57,43 +57,6 @@ def check_database_last_updated():
return {"last_updated": result}


def remove_file(path: str) -> None:
"""Used for removing temp file dir and its all content after zip file is delivered to user"""
try:
shutil.rmtree(path)
except OSError as ex:
logging.error("Error: %s - %s.", ex.filename, ex.strerror)


def watch_s3_upload(url: str, path: str) -> None:
"""Watches upload of s3 either it is completed or not and removes the temp file after completion

Args:
url (_type_): url generated by the script where data will be available
path (_type_): path where temp file is located at
"""
start_time = time.time()
remove_temp_file = True
check_call = requests.head(url).status_code
if check_call != 200:
logging.debug("Upload is not done yet waiting ...")
while check_call != 200: # check until status is not green
check_call = requests.head(url).status_code
if time.time() - start_time > 300:
logging.error(
"Upload time took more than 5 min , Killing watch : %s , URL : %s",
path,
url,
)
remove_temp_file = False # don't remove the file if upload fails
break
time.sleep(3) # check each 3 second
# once it is verfied file is uploaded finally remove the file
if remove_temp_file:
logging.debug("File is uploaded at %s , flushing out from %s", url, path)
os.unlink(path)


@router.post("/snapshot/", response_model=SnapshotResponse)
@limiter.limit(f"{export_rate_limit}/minute")
@version(1)
Expand Down Expand Up @@ -486,18 +449,17 @@ def get_osm_current_snapshot_as_plain_geojson(
Returns:
Featurecollection: Geojson
"""
if not (user.role == UserRole.STAFF or user.role == UserRole.ADMIN):
area_m2 = area(json.loads(params.geometry.json()))
area_km2 = area_m2 * 1e-6
if area_km2 > 30:
raise HTTPException(
status_code=400,
detail=[
{
"msg": f"""Polygon Area {int(area_km2)} Sq.KM is higher than Threshold : 30 Sq.KM"""
}
],
)
area_m2 = area(json.loads(params.geometry.json()))
area_km2 = area_m2 * 1e-6
if area_km2 > 10:
raise HTTPException(
status_code=400,
detail=[
{
"msg": f"""Polygon Area {int(area_km2)} Sq.KM is higher than Threshold : 10 Sq.KM"""
}
],
)
params.output_type = "geojson" # always geojson
result = RawData(params).extract_plain_geojson()
return result
Expand Down
6 changes: 3 additions & 3 deletions API/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
from src.validation.models import SnapshotTaskResponse

from .api_worker import celery
from .auth import AuthUser, admin_required, login_required
from .auth import AuthUser, admin_required, login_required, staff_required

router = APIRouter(prefix="/tasks")
router = APIRouter(prefix="/tasks", tags=["Tasks"])


@router.get("/status/{task_id}/", response_model=SnapshotTaskResponse)
Expand Down Expand Up @@ -40,7 +40,7 @@ def get_task_status(task_id):

@router.get("/revoke/{task_id}/")
@version(1)
def revoke_task(task_id, user: AuthUser = Depends(login_required)):
def revoke_task(task_id, user: AuthUser = Depends(staff_required)):
"""Revokes task , Terminates if it is executing

Args:
Expand Down
10 changes: 9 additions & 1 deletion backend/raw_backend
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,15 @@ if __name__ == "__main__":

if len(update_cmd_list) > 1:
run_subprocess_cmd_parallel(update_cmd_list)

if args.insert:
users_table = [
"psql",
"-a",
"-f",
os.path.join(working_dir, "sql/users.sql"),
]
run_subprocess_cmd(users_table)
print("Users table created")
if args.insert or args.post_index:
## build post indexes
basic_index_cmd = [
Expand Down
7 changes: 7 additions & 0 deletions backend/sql/users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DROP TABLE if exists public.users;
CREATE TABLE public.users (
osm_id int8 NOT NULL,
role int4 NULL DEFAULT 3,
CONSTRAINT users_un UNIQUE (osm_id),
CONSTRAINT valid_role CHECK (role IN (1, 2, 3))
);
15 changes: 13 additions & 2 deletions docs/src/installation/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@

The default configuration file is an ini-style text file named `config.txt` in the project root.

## Users Table

Users table is present on ```backend/sql/users.sql``` Make sure you have it before moving forward

```
psql -a -f backend/sql/users.sql
```
& Add your admin's OSM ID as admin

```
INSERT INTO users (osm_id, role) VALUES (1234, 1);
```

## Sections

The following sections are recognised.
Expand All @@ -34,7 +47,6 @@ The following are the different configuration options that are accepted.
| `LOGIN_REDIRECT_URI` | `LOGIN_REDIRECT_URI` | `[OAUTH]` | _none_ | Redirect URL set in the OAuth2 application | REQUIRED |
| `APP_SECRET_KEY` | `APP_SECRET_KEY` | `[OAUTH]` | _none_ | High-entropy string generated for the application | REQUIRED |
| `OSM_URL` | `OSM_URL` | `[OAUTH]` | `https://www.openstreetmap.org` | OSM instance Base URL | OPTIONAL |
| `ADMIN_IDS` | `ADMIN_IDS` | `[OAUTH]` | `00000` | List of Admin OSMId separated by , | OPTIONAL |
| `LOG_LEVEL` | `LOG_LEVEL` | `[API_CONFIG]` | `debug` | Application log level; info,debug,warning,error | OPTIONAL |
| `RATE_LIMITER_STORAGE_URI` | `RATE_LIMITER_STORAGE_URI` | `[API_CONFIG]` | `redis://redis:6379` | Redis connection string for rate-limiter data | OPTIONAL |
| `RATE_LIMIT_PER_MIN` | `RATE_LIMIT_PER_MIN` | `[API_CONFIG]` | `5` | Number of requests per minute before being rate limited | OPTIONAL |
Expand Down Expand Up @@ -68,7 +80,6 @@ The following are the different configuration options that are accepted.
| `LOGIN_REDIRECT_URI` | TBD | Yes | No |
| `APP_SECRET_KEY` | TBD | Yes | No |
| `OSM_URL` | TBD | Yes | No |
| `ADMIN_IDS` | TBD | Yes | No |
| `LOG_LEVEL` | `[API_CONFIG]` | Yes | Yes |
| `RATE_LIMITER_STORAGE_URI` | `[API_CONFIG]` | Yes | No |
| `RATE_LIMIT_PER_MIN` | `[API_CONFIG]` | Yes | No |
Expand Down
Loading
Loading