Skip to content

Commit

Permalink
Merge pull request #170 from hotosm/feature/user_login
Browse files Browse the repository at this point in the history
Feature:  User Login & Authorization
  • Loading branch information
kshitijrajsharma authored Nov 17, 2023
2 parents 0176c64 + e6e26b2 commit 04328f9
Show file tree
Hide file tree
Showing 10 changed files with 327 additions and 88 deletions.
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

0 comments on commit 04328f9

Please sign in to comment.