Skip to content

Commit

Permalink
feat: cookie based authentication (#1091)
Browse files Browse the repository at this point in the history
* feat(backend): cookie based authentication and logout

* refactor: frontend

login via cookie from backend

* fix(frontend): revoke token cookie on sign out
  • Loading branch information
spwoodcock authored Jan 10, 2024
1 parent c104c2d commit 9d5f31b
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 31 deletions.
77 changes: 63 additions & 14 deletions src/backend/app/auth/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@
# along with FMTM. If not, see <https:#www.gnu.org/licenses/>.
#

"""Auth routes, using OSM OAuth2 endpoints."""
"""Auth routes, to login, logout, and get user details."""

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from loguru import logger as log
from sqlalchemy.orm import Session

from app.auth.osm import AuthUser, init_osm_auth, login_required
from app.config import settings
from app.db import database
from app.db.db_models import DbUser
from app.users import user_crud
from app.auth.osm import AuthUser, init_osm_auth, login_required

router = APIRouter(
prefix="/auth",
Expand Down Expand Up @@ -57,31 +58,75 @@ async def login_url(request: Request, osm_auth=Depends(init_osm_auth)):

@router.get("/callback/")
async def callback(request: Request, osm_auth=Depends(init_osm_auth)):
"""Performs token exchange between OpenStreetMap and Export tool API.
"""Performs oauth token exchange with OpenStreetMap.
Core will use Oauth secret key from configuration while deserializing token,
provides access token that can be used for authorized endpoints.
Provides an access token that can be used for authenticating other endpoints.
Also returns a cookie containing the access token for persistence in frontend apps.
Args:
request: The GET request.
request: The response, including a cookie.
osm_auth: The Auth object from osm-login-python.
Returns:
access_token(string): The access token provided by the login URL request.
access_token (string): The access token provided by the login URL request.
"""
log.debug(f"Callback url requested: {request.url}")

# Enforce https callback url
# Enforce https callback url for openstreetmap.org
callback_url = str(request.url).replace("http://", "https://")

access_token = osm_auth.callback(callback_url)

log.debug(f"Access token returned: {access_token}")
return JSONResponse(content={"access_token": access_token}, status_code=200)
# Get access token
access_token = osm_auth.callback(callback_url).get("access_token")
log.debug(f"Access token returned of length {len(access_token)}")
response = JSONResponse(content={"access_token": access_token}, status_code=200)

# Set cookie
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
log.debug(
f"Setting cookie in response named '{cookie_name}' with params: "
f"max_age=259200 | expires=259200 | path='/' | "
f"domain={settings.FMTM_DOMAIN} | httponly=True | samesite='lax' | "
f"secure={False if settings.DEBUG else True}"
)
response.set_cookie(
key=cookie_name,
value=access_token,
max_age=31536000, # OSM currently has no expiry
expires=31536000, # OSM currently has no expiry,
path="/",
domain=settings.FMTM_DOMAIN,
secure=False if settings.DEBUG else True,
httponly=True,
samesite="lax",
)
return response


@router.get("/logout/")
async def logout():
"""Reset httpOnly cookie to sign out user."""
response = Response(status_code=200)
# Reset cookie (logout)
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
log.debug(f"Resetting cookie in response named '{cookie_name}'")
response.set_cookie(
key=cookie_name,
value="",
max_age=0, # Set to expire immediately
expires=0, # Set to expire immediately
path="/",
domain=settings.FMTM_DOMAIN,
secure=False if settings.DEBUG else True,
httponly=True,
samesite="lax",
)
return response


@router.get("/me/", response_model=AuthUser)
async def my_data(
request: Request,
db: Session = Depends(database.get_db),
user_data: AuthUser = Depends(login_required),
):
Expand All @@ -108,9 +153,13 @@ async def my_data(
"Please contact the administrator."
),
)

# Add user to database
db_user = DbUser(id=user_data["id"], username=user_data["username"], profile_img = user_data["img_url"])
db_user = DbUser(
id=user_data["id"],
username=user_data["username"],
profile_img=user_data["img_url"],
)
db.add(db_user)
db.commit()
else:
Expand Down
37 changes: 34 additions & 3 deletions src/backend/app/auth/osm.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
# Copyright (c) 2022, 2023 Humanitarian OpenStreetMap Team
#
# This file is part of FMTM.
#
# FMTM is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# FMTM is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with FMTM. If not, see <https:#www.gnu.org/licenses/>.
#

"""Auth methods related to OSM OAuth2."""

import os
from typing import Optional

from fastapi import Header
from fastapi import Header, HTTPException, Request
from loguru import logger as log
from osm_login_python.core import Auth
from pydantic import BaseModel

from ..config import settings
from app.config import settings

if settings.DEBUG:
# Required as callback url is http during dev
Expand All @@ -29,6 +50,16 @@ def init_osm_auth():
)


def login_required(access_token: str = Header(...)):
def login_required(request: Request, access_token: str = Header(None)):
osm_auth = init_osm_auth()

# Attempt extract from cookie if access token not passed
if not access_token:
cookie_name = settings.FMTM_DOMAIN.replace(".", "_")
log.debug(f"Extracting token from cookie {cookie_name}")
access_token = request.cookies.get(cookie_name)

if not access_token:
raise HTTPException(status_code=401, detail="No access token provided")

return osm_auth.deserialize_access_token(access_token)
20 changes: 14 additions & 6 deletions src/frontend/src/utilfunctions/login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import environment from '../environment';
import { createPopup } from './createPopup';

export const createLoginWindow = (redirectTo) => {
Expand Down Expand Up @@ -26,19 +25,17 @@ export const createLoginWindow = (redirectTo) => {
const state = event.data.state;
const callback_url = `${import.meta.env.VITE_API_URL}/auth/callback/?code=${authCode}&state=${state}`;

fetch(callback_url)
fetch(callback_url, { credentials: 'include' })
.then((resp) => resp.json())
.then((res) => {
fetch(`${import.meta.env.VITE_API_URL}/auth/me/`, {
headers: {
'access-token': res.access_token.access_token,
},
credentials: 'include',
})
.then((resp) => resp.json())
.then((userRes) => {
const params = new URLSearchParams({
username: userRes.user_data.username,
osm_oauth_token: res.access_token.access_token,
osm_oauth_token: res.access_token,
id: userRes.user_data.id,
picture: userRes.user_data.img_url,
redirect_to: redirectTo,
Expand All @@ -56,3 +53,14 @@ export const createLoginWindow = (redirectTo) => {
window.addEventListener('message', handleLoginPopup);
});
};

export const revokeCookie = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/logout/`, { credentials: 'include' });
if (!response.ok) {
console.error('/auth/logout endpoint did not return 200 response');
}
} catch (error) {
throw error;
}
};
21 changes: 17 additions & 4 deletions src/frontend/src/utilities/CustomDrawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import CoreModules from '../shared/CoreModules';
import AssetModules from '../shared/AssetModules';
import { NavLink } from 'react-router-dom';
import { createLoginWindow } from '../utilfunctions/login';
import { createLoginWindow, revokeCookie } from '../utilfunctions/login';
import { CommonActions } from '../store/slices/CommonSlice';
import { LoginActions } from '../store/slices/LoginSlice';
import { ProjectActions } from '../store/slices/ProjectSlice';

Expand Down Expand Up @@ -85,10 +86,22 @@ export default function CustomDrawer({ open, placement, size, type, onClose, onS
},
];

const handleOnSignOut = () => {
const handleOnSignOut = async () => {
setOpen(false);
dispatch(LoginActions.signOut(null));
dispatch(ProjectActions.clearProjects([]));
try {
await revokeCookie();
dispatch(LoginActions.signOut(null));
dispatch(ProjectActions.clearProjects([]));
} catch {
dispatch(
CommonActions.SetSnackBar({
open: true,
message: 'Failed to sign out.',
variant: 'error',
duration: 2000,
}),
);
}
};

return (
Expand Down
21 changes: 17 additions & 4 deletions src/frontend/src/utilities/PrimaryAppBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import CustomizedImage from '../utilities/CustomizedImage';
import { ThemeActions } from '../store/slices/ThemeSlice';
import CoreModules from '../shared/CoreModules';
import AssetModules from '../shared/AssetModules';
import { CommonActions } from '../store/slices/CommonSlice';
import { LoginActions } from '../store/slices/LoginSlice';
import { ProjectActions } from '../store/slices/ProjectSlice';
import { createLoginWindow } from '../utilfunctions/login';
import { createLoginWindow, revokeCookie } from '../utilfunctions/login';
import { useState } from 'react';

export default function PrimaryAppBar() {
Expand Down Expand Up @@ -47,10 +48,22 @@ export default function PrimaryAppBar() {
},
};

const handleOnSignOut = () => {
const handleOnSignOut = async () => {
setOpen(false);
dispatch(LoginActions.signOut(null));
dispatch(ProjectActions.clearProjects([]));
try {
await revokeCookie();
dispatch(LoginActions.signOut(null));
dispatch(ProjectActions.clearProjects([]));
} catch {
dispatch(
CommonActions.SetSnackBar({
open: true,
message: 'Failed to sign out.',
variant: 'error',
duration: 2000,
}),
);
}
};

const { type, windowSize } = windowDimention();
Expand Down

0 comments on commit 9d5f31b

Please sign in to comment.