-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #79 from UpstreamDataInc/dev_api_tokens
- Loading branch information
Showing
13 changed files
with
150 additions
and
100 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"ignore": "H030,H031" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,9 @@ | ||
from fastapi import APIRouter, Depends | ||
|
||
from goosebit.auth import Authentication | ||
from goosebit.auth import validate_current_user | ||
|
||
from . import telemetry, v1 | ||
|
||
router = APIRouter(prefix="/api", dependencies=[Depends(Authentication())]) | ||
router = APIRouter(prefix="/api", dependencies=[Depends(validate_current_user)]) | ||
router.include_router(telemetry.router) | ||
router.include_router(v1.router) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,115 +1,118 @@ | ||
from __future__ import annotations | ||
|
||
import logging | ||
from typing import Annotated | ||
|
||
from argon2.exceptions import VerifyMismatchError | ||
from fastapi import Depends, HTTPException | ||
from fastapi.requests import HTTPConnection, Request | ||
from fastapi.security import SecurityScopes | ||
from fastapi.security import OAuth2PasswordBearer, SecurityScopes | ||
from joserfc import jwt | ||
from joserfc.errors import BadSignatureError | ||
|
||
from goosebit.settings import PWD_CXT, SECRET, USERS | ||
from goosebit.settings import PWD_CXT, USERS, config | ||
from goosebit.settings.schema import User | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class Authentication: | ||
def __init__(self, redirect: bool = False): | ||
if redirect: | ||
self.status_code = 302 | ||
else: | ||
self.status_code = 401 | ||
oauth2_auth = OAuth2PasswordBearer(tokenUrl="login", auto_error=False) | ||
|
||
def __call__(self, connection: HTTPConnection): | ||
session_id = connection.cookies.get("session_id") | ||
headers = {"location": str(connection.url_for("login"))} | ||
if session_id is None: | ||
raise HTTPException( | ||
status_code=self.status_code, | ||
headers=headers, | ||
detail="Invalid session ID", | ||
) | ||
user = get_user_from_session(session_id) | ||
if user is None: | ||
raise HTTPException( | ||
status_code=self.status_code, | ||
headers=headers, | ||
detail="Invalid username", | ||
) | ||
return user | ||
|
||
async def session_auth(connection: HTTPConnection) -> str: | ||
return connection.cookies.get("session_id") | ||
|
||
async def authenticate_user(request: Request): | ||
form_data = await request.form() | ||
username = form_data.get("username") | ||
password = form_data.get("password") | ||
|
||
def create_token(username: str) -> str: | ||
return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key) | ||
|
||
|
||
def get_user_from_token(token: str) -> User | None: | ||
if token is None: | ||
return | ||
try: | ||
token_data = jwt.decode(token, config.secret_key) | ||
username = token_data.claims["username"] | ||
return USERS.get(username) | ||
except (BadSignatureError, LookupError, ValueError): | ||
pass | ||
|
||
|
||
def login_user(username: str, password: str) -> str: | ||
user = USERS.get(username) | ||
if user is None: | ||
raise HTTPException( | ||
status_code=302, | ||
headers={"location": str(request.url_for("login"))}, | ||
detail="Invalid credentials", | ||
status_code=401, | ||
detail="Invalid username or password", | ||
headers={"WWW-Authenticate": "Bearer"}, | ||
) | ||
try: | ||
if not PWD_CXT.verify(user.hashed_pwd, password): | ||
raise HTTPException( | ||
status_code=302, | ||
headers={"location": str(request.url_for("login"))}, | ||
detail="Invalid credentials", | ||
) | ||
PWD_CXT.verify(user.hashed_pwd, password) | ||
except VerifyMismatchError: | ||
raise HTTPException( | ||
status_code=302, | ||
headers={"location": str(request.url_for("login"))}, | ||
detail="Invalid credentials", | ||
status_code=401, | ||
detail="Invalid username or password", | ||
headers={"WWW-Authenticate": "Bearer"}, | ||
) | ||
return create_token(user.username) | ||
|
||
|
||
def get_current_user( | ||
session_token: Annotated[str, Depends(session_auth)] = None, | ||
oauth2_token: Annotated[str, Depends(oauth2_auth)] = None, | ||
) -> User: | ||
session_user = get_user_from_token(session_token) | ||
oauth2_user = get_user_from_token(oauth2_token) | ||
user = session_user or oauth2_user | ||
return user | ||
|
||
|
||
def create_session(username: str) -> str: | ||
return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=SECRET) | ||
# using | Request because oauth2_auth.__call__ expects is | ||
async def get_user_from_request(connection: HTTPConnection | Request) -> User: | ||
token = await session_auth(connection) or await oauth2_auth(connection) | ||
return get_user_from_token(token) | ||
|
||
|
||
def auto_redirect(request: Request): | ||
session_id = request.cookies.get("session_id") | ||
if get_user_from_session(session_id) is None: | ||
return request | ||
raise HTTPException( | ||
status_code=302, | ||
headers={"location": str(request.url_for("ui_root"))}, | ||
detail="Already logged in", | ||
) | ||
def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]): | ||
if user is None: | ||
raise HTTPException( | ||
status_code=302, | ||
headers={"location": str(connection.url_for("login_get"))}, | ||
detail="Invalid username", | ||
) | ||
|
||
|
||
def get_user_from_session(session_id: str): | ||
if session_id is None: | ||
return | ||
try: | ||
session_data = jwt.decode(session_id, SECRET) | ||
return session_data.claims["username"] | ||
except (BadSignatureError, LookupError, ValueError): | ||
pass | ||
def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]): | ||
if user is not None: | ||
raise HTTPException( | ||
status_code=302, | ||
headers={"location": str(connection.url_for("ui_root"))}, | ||
detail="Already logged in", | ||
) | ||
|
||
|
||
def get_current_user(request: Request): | ||
session_id = request.cookies.get("session_id") | ||
user = get_user_from_session(session_id) | ||
def validate_current_user(user: Annotated[User, Depends(get_current_user)]): | ||
if user is None: | ||
return None | ||
return USERS[user] | ||
raise HTTPException( | ||
status_code=401, | ||
detail="Not authenticated", | ||
headers={"WWW-Authenticate": "Bearer"}, | ||
) | ||
return user | ||
|
||
|
||
def validate_user_permissions( | ||
connection: HTTPConnection, | ||
security: SecurityScopes, | ||
username: str = Depends(Authentication()), | ||
user: User = Depends(get_current_user), | ||
) -> HTTPConnection: | ||
user = USERS[username] | ||
if security.scopes is None: | ||
return connection | ||
for scope in security.scopes: | ||
if scope not in user.permissions: | ||
logger.warning(f"User {username} does not have permission {scope}") | ||
logger.warning(f"User {user.username} does not have permission {scope}") | ||
raise HTTPException( | ||
status_code=403, | ||
detail="Not enough permissions", | ||
headers={"WWW-Authenticate": "Bearer"}, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
loginForm = document.getElementById("login_form"); | ||
|
||
async function login() { | ||
const formData = new FormData(loginForm); | ||
|
||
try { | ||
const response = await fetch("/login", { | ||
method: "POST", | ||
body: formData, | ||
}); | ||
tokenData = await response.json(); | ||
document.cookie = `session_id=${tokenData.access_token}; path=/`; | ||
location.reload(); | ||
} catch (e) { | ||
// handle form errors later | ||
console.error(e); | ||
} | ||
} | ||
|
||
loginForm.addEventListener("submit", (event) => { | ||
event.preventDefault(); | ||
login(); | ||
}); |
Oops, something went wrong.