start work with fake openid add for testing

This commit is contained in:
Georges-Antoine Assi
2024-08-04 00:37:30 -04:00
parent ebd0c072d2
commit 0fae870837
12 changed files with 2933 additions and 6 deletions

1
.gitignore vendored
View File

@@ -37,6 +37,7 @@ __pycache__
# database
mariadb
*.sqlite
# used to mock the library/config/mounts/etc while testing
frontend/assets/romm

View File

@@ -61,6 +61,13 @@ DISABLE_DOWNLOAD_ENDPOINT_AUTH = (
os.environ.get("DISABLE_DOWNLOAD_ENDPOINT_AUTH", "false") == "true"
)
# OAUTH
OAUTH_ENABLED: Final = os.environ.get("OAUTH_ENABLED", "false") == "true"
OAUTH_CLIENT_ID: Final = os.environ.get("OAUTH_CLIENT_ID", "")
OAUTH_CLIENT_SECRET: Final = os.environ.get("OAUTH_CLIENT_SECRET", "")
OAUTH_REDIRECT_URI: Final = os.environ.get("OAUTH_REDIRECT_URI", "")
OAUTH_SERVER_METADATA_URL: Final = os.environ.get("OAUTH_SERVER_METADATA_URL", "")
# SCANS
SCAN_TIMEOUT: Final = int(os.environ.get("SCAN_TIMEOUT", 60 * 60 * 4)) # 4 hours

View File

@@ -1,8 +1,16 @@
from typing import Any
from fastapi import Security
from authlib.integrations.starlette_client import OAuth
from config import (
OAUTH_CLIENT_ID,
OAUTH_CLIENT_SECRET,
OAUTH_ENABLED,
OAUTH_REDIRECT_URI,
OAUTH_SERVER_METADATA_URL,
)
from fastapi import Depends, Security
from fastapi.security.http import HTTPBasic
from fastapi.security.oauth2 import OAuth2PasswordBearer
from fastapi.security.oauth2 import OAuth2AuthorizationCodeBearer, OAuth2PasswordBearer
from fastapi.types import DecoratedCallable
from handler.auth.base_handler import (
DEFAULT_SCOPES_MAP,
@@ -10,6 +18,7 @@ from handler.auth.base_handler import (
WRITE_SCOPES_MAP,
)
from starlette.authentication import requires
from starlette.config import Config
oauth2_password_bearer = OAuth2PasswordBearer(
tokenUrl="/token",
@@ -21,6 +30,29 @@ oauth2_password_bearer = OAuth2PasswordBearer(
},
)
config = Config(
environ={
"OAUTH_ENABLED": OAUTH_ENABLED,
"OAUTH_CLIENT_ID": OAUTH_CLIENT_ID,
"OAUTH_CLIENT_SECRET": OAUTH_CLIENT_SECRET,
"OAUTH_REDIRECT_URI": OAUTH_REDIRECT_URI,
"OAUTH_SERVER_METADATA_URL": OAUTH_SERVER_METADATA_URL,
}
)
oauth = OAuth(config=config)
oauth.register(
name="openid",
client_id=config.get("OAUTH_CLIENT_ID"),
client_secret=config.get("OAUTH_CLIENT_SECRET"),
server_metadata_url=config.get("OAUTH_SERVER_METADATA_URL"),
client_kwargs={"scope": "openid profile email"},
)
oauth2_autorization_code_bearer = OAuth2AuthorizationCodeBearer(
authorizationUrl="/auth/openid",
tokenUrl="/token",
)
def protected_route(
method: Any,
@@ -38,6 +70,13 @@ def protected_route(
scopes=scopes or [],
),
Security(dependency=HTTPBasic(auto_error=False)),
(
Security(
dependency=oauth2_autorization_code_bearer, scopes=scopes or []
)
if OAUTH_ENABLED
else Depends(lambda: None)
),
],
**kwargs,
)(fn)

View File

@@ -1,6 +1,8 @@
from datetime import datetime, timedelta
from typing import Annotated, Final
from config import OAUTH_ENABLED, OAUTH_REDIRECT_URI
from decorators.auth import oauth
from endpoints.forms.identity import OAuth2RequestForm
from endpoints.responses import MessageResponse
from endpoints.responses.oauth import TokenResponse
@@ -134,7 +136,8 @@ async def token(form_data: Annotated[OAuth2RequestForm, Depends()]) -> TokenResp
@router.post("/login")
def login(
request: Request, credentials=Depends(HTTPBasic()) # noqa
request: Request,
credentials=Depends(HTTPBasic()), # noqa
) -> MessageResponse:
"""Session login endpoint
@@ -167,6 +170,53 @@ def login(
return {"msg": "Successfully logged in"}
@router.get("/login/openid")
async def login_via_openid(request: Request):
"""OAuth2 login endpoint
Args:
request (Request): Fastapi Request object
Returns:
RedirectResponse: Redirect to OAuth2 provider
"""
if not OAUTH_ENABLED:
raise DisabledException
return await oauth.openid.authorize_redirect(request, OAUTH_REDIRECT_URI)
@router.get("/auth/openid")
async def auth_openid(request: Request):
"""OAuth2 callback endpoint
Args:
request (Request): Fastapi Request object
Returns:
RedirectResponse: Redirect to home page
"""
token = await oauth.openid.authorize_access_token(request)
user, claims = await oauth_handler.get_current_active_user_from_bearer_token(token)
if not user:
raise AuthCredentialsException
if not user.enabled:
raise DisabledException
request.session.update({"iss": "romm:auth", "sub": user.username})
# Update last login and active times
db_user_handler.update_user(
user.id, {"last_login": datetime.now(), "last_active": datetime.now()}
)
return {"msg": "Successfully logged in"}
@router.post("/logout")
def logout(request: Request) -> MessageResponse:
"""Session logout endpoint

View File

@@ -19,11 +19,16 @@ async function login(
);
}
async function loginOIDC(): Promise<{ data: MessageResponse }> {
return api.get("/login/openid");
}
async function logout(): Promise<{ data: MessageResponse }> {
return api.post("/logout", {});
return api.post("/logout");
}
export default {
login,
loginOIDC,
logout,
};

View File

@@ -43,6 +43,19 @@ async function login() {
logging.value = false;
});
}
async function loginOIDC() {
logging.value = true;
await identityApi
.loginOIDC()
.then(() => {
const next = (router.currentRoute.value.query?.next || "/").toString();
router.push(next);
})
.finally(() => {
logging.value = false;
});
}
</script>
<template>
@@ -103,6 +116,30 @@ async function login() {
/>
</template>
</v-btn>
<v-btn
type="submit"
:disabled="logging"
:variant="'text'"
class="bg-terciary"
block
:loading="logging"
@click="loginOIDC()"
>
<span>Login with OIDC</span>
<template #append>
<v-icon class="text-romm-accent-1"
>mdi-chevron-right-circle-outline</v-icon
>
</template>
<template #loader>
<v-progress-circular
color="romm-accent-1"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
</v-form>
</v-col>
</v-row>

96
oidc-provider/index.js Normal file
View File

@@ -0,0 +1,96 @@
import { config } from "dotenv";
import { resolve } from "path";
import { Provider } from "oidc-provider";
import express from "express";
import SQLiteAdapter from "./sqlite_adapter.js";
config({ path: resolve("../.env") });
const DEFAULT_SCOPES_MAP = {
"me.read": "View your profile",
"me.write": "Modify your profile",
"roms.read": "View ROMs",
"platforms.read": "View platforms",
"assets.read": "View assets",
"assets.write": "Modify assets",
"firmware.read": "View firmware",
"roms.user.read": "View user-rom properties",
"roms.user.write": "Modify user-rom properties",
"collections.read": "View collections",
"collections.write": "Modify collections",
};
const WRITE_SCOPES_MAP = {
"roms.write": "Modify ROMs",
"platforms.write": "Modify platforms",
"firmware.write": "Modify firmware",
};
const FULL_SCOPES_MAP = {
"users.read": "View users",
"users.write": "Modify users",
"tasks.run": "Run tasks",
};
const ALL_SCOPES = [
"openid",
"profile",
"email",
...Object.keys(DEFAULT_SCOPES_MAP),
...Object.keys(WRITE_SCOPES_MAP),
...Object.keys(FULL_SCOPES_MAP),
];
const app = express();
const clients = [
{
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
grant_types: ["authorization_code"],
redirect_uris: [process.env.OAUTH_REDIRECT_URI],
response_types: ["code"],
scope: ALL_SCOPES.join(" "),
},
];
const oidc = new Provider("http://localhost:4000", {
clients,
features: {
introspection: { enabled: true },
revocation: { enabled: true },
devInteractions: { enabled: true },
},
pkce: { methods: ["S256"], required: () => false },
formats: {
AccessToken: "jwt",
},
jwks: {
keys: [
{
kty: "RSA",
kid: "test-key",
use: "sig",
e: "AQAB",
n: "test-key-n",
d: "test-key-d",
p: "test-key-p",
q: "test-key-q",
dp: "test-key-dp",
dq: "test-key-dq",
qi: "test-key-qi",
},
],
},
cookies: {
keys: [process.env.SIGNING_COOKIE_A, process.env.SIGNING_COOKIE_B],
},
scopes: ALL_SCOPES,
adapter: SQLiteAdapter,
});
app.use("/oidc", oidc.callback());
app.listen(4000, () => {
console.log("OIDC provider listening on http://localhost:4000");
});

2600
oidc-provider/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "oidc-provider",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"better-sqlite3": "^11.1.2",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"oidc-provider": "^8.5.1",
"sqlite3": "^5.1.7"
}
}

View File

@@ -0,0 +1,58 @@
import Database from "better-sqlite3";
import { resolve } from "path";
const dbPath = resolve("./data/oidc_data.sqlite");
const db = new Database(dbPath);
// Initialize the database schema
db.exec(`
CREATE TABLE IF NOT EXISTS oidc (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
payload TEXT NOT NULL,
expiresAt INTEGER
)
`);
class SQLiteAdapter {
constructor(name) {
this.name = name;
}
async upsert(id, payload, expiresIn) {
const expiresAt = expiresIn ? Date.now() + expiresIn * 1000 : null;
const stmt = db.prepare(`
INSERT INTO oidc (id, name, payload, expiresAt)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
payload=excluded.payload,
expiresAt=excluded.expiresAt
`);
stmt.run(id, this.name, JSON.stringify(payload), expiresAt);
}
async find(id) {
const stmt = db.prepare(`SELECT * FROM oidc WHERE id = ? AND name = ?`);
const record = stmt.get(id, this.name);
if (!record) return undefined;
if (record.expiresAt && record.expiresAt < Date.now()) {
await this.destroy(id);
return undefined;
}
return JSON.parse(record.payload);
}
async destroy(id) {
const stmt = db.prepare(`DELETE FROM oidc WHERE id = ? AND name = ?`);
stmt.run(id, this.name);
}
async revokeByGrantId(grantId) {
const stmt = db.prepare(
`DELETE FROM oidc WHERE json_extract(payload, '$.grantId') = ? AND name = ?`,
);
stmt.run(grantId, this.name);
}
}
export default SQLiteAdapter;

17
poetry.lock generated
View File

@@ -90,6 +90,20 @@ files = [
{file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
]
[[package]]
name = "authlib"
version = "1.3.1"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
python-versions = ">=3.8"
files = [
{file = "Authlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:d35800b973099bbadc49b42b256ecb80041ad56b7fe1216a362c7943c088f377"},
{file = "authlib-1.3.1.tar.gz", hash = "sha256:7ae843f03c06c5c0debd63c9db91f9fda64fa62a42a77419fa15fbb7e7a58917"},
]
[package.dependencies]
cryptography = "*"
[[package]]
name = "bcrypt"
version = "4.1.3"
@@ -1765,7 +1779,6 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
@@ -2695,4 +2708,4 @@ multidict = ">=4.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "f8d8c12320cb8e5b47b182c507b4c988185910e60f7fb162f9508685072f2214"
content-hash = "16d7552ecd58bac81f8a3957bc251a30c68943ad99d5272d35168100865b0342"

View File

@@ -41,6 +41,7 @@ yarl = "^1.9.4"
joserfc = "^0.9.0"
pillow = "^10.3.0"
certifi = "2024.07.04"
authlib = "^1.3.1"
[tool.poetry.group.test.dependencies]
fakeredis = "^2.21.3"