Skip to content
Open
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
29 changes: 29 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]
black = "*"
flake8 = "*"
rope = "*"
isort = "*"

[packages]
whoosh = "==2.7.4"
fastapi = "==0.118.3"
uvicorn = {extras = ["standard"], version = "==0.37.0"}
aiofiles = "==25.1.0"
python-jose = {extras = ["cryptography"], version = "==3.5.0"}
pyotp = "==2.9.0"
qrcode = "==8.2"
python-multipart = "==0.0.20"
SQLAlchemy = ">=2.0.0"
aiosqlite = ">=0.19.0"
httpx = ">=0.23"

[requires]
python_version = "3.11"

[pipenv]
allow_prereleases = true
1,132 changes: 1,132 additions & 0 deletions Pipfile.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions client/components/GitHubIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<svg viewBox="0 0 24 24" class="h-5 w-5 fill-white" aria-hidden="true">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
</template>
5 changes: 5 additions & 0 deletions client/components/OIDCIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<svg viewBox="0 0 24 24" class="h-5 w-5 fill-white" aria-hidden="true">
<path d="M12 1C9.238 1 7 3.238 7 6v2H5a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6c0-2.762-2.238-5-5-5zm0 2c1.658 0 3 1.342 3 3v2H9V6c0-1.658 1.342-3 3-3zm0 9a2 2 0 1 1 0 4 2 2 0 0 1 0-4z"/>
</svg>
</template>
1 change: 1 addition & 0 deletions client/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export const authTypes = {
readOnly: "read_only",
password: "password",
totp: "totp",
oidc: "oidc",
};
73 changes: 24 additions & 49 deletions client/views/LogIn.vue
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,73 +1,57 @@
<template>
<div class="flex h-full flex-col items-center justify-center">
<Logo class="mb-5" />
<form @submit.prevent="logIn" class="flex max-w-80 flex-col items-center">
<TextInput
v-model="username"
id="username"
placeholder="Username"
class="mb-1"
autocomplete="username"
required
/>
<TextInput
v-model="password"
id="password"
placeholder="Password"
type="password"
class="mb-1"
autocomplete="current-password"
required
/>
<TextInput
v-if="globalStore.config.authType == authTypes.totp"
v-model="totp"
id="one-time-code"
placeholder="2FA Code"
class="mb-1"
autocomplete="one-time-code"
required
/>
<div v-if="globalStore.config.authType == authTypes.oidc" class="flex max-w-80 flex-col items-center">
<a v-if="globalStore.config.authProvider == 'github'" href="/api/auth/oidc/login" class="flex items-center gap-2 rounded-md bg-[#24292e] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[#3a3f44] transition-colors">
<GitHubIcon />
Continue with GitHub
</a>
<a v-else href="/api/auth/oidc/login" class="flex items-center gap-2 rounded-md bg-theme-brand px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition-opacity">
<OIDCIcon />
Continue with OIDC
</a>
</div>
<form v-if="globalStore.config.authType != authTypes.oidc" @submit.prevent="logIn" class="flex max-w-80 flex-col items-center">
<TextInput v-model="username" id="username" placeholder="Username" class="mb-1" autocomplete="username" required />
<TextInput v-model="password" id="password" placeholder="Password" type="password" class="mb-1" autocomplete="current-password" required />
<TextInput v-if="globalStore.config.authType == authTypes.totp" v-model="totp" id="one-time-code" placeholder="2FA Code" class="mb-1" autocomplete="one-time-code" required />
<button v-if="globalStore.config.authType == authTypes.totp" type="button" @click="showTotpModal = true" class="mb-2 text-xs text-theme-brand hover:underline self-start">
Show QR code for authenticator setup
</button>
<div class="mb-4 flex">
<input
type="checkbox"
id="remember-me"
v-model="rememberMe"
class="mr-1"
/>
<input type="checkbox" id="remember-me" v-model="rememberMe" class="mr-1" />
<label for="remember-me">Remember Me</label>
</div>
<CustomButton :iconPath="mdilLogin" label="Log In" />
</form>
<TotpSetupModal :show="showTotpModal" @close="showTotpModal = false" />
</div>
</template>

<script setup>
import { mdilLogin } from "@mdi/light-js";
import { useToast } from "primevue/usetoast";
import { ref } from "vue";
import { useRouter } from "vue-router";

import { apiErrorHandler, getToken } from "../api.js";
import CustomButton from "../components/CustomButton.vue";
import GitHubIcon from "../components/GitHubIcon.vue";
import OIDCIcon from "../components/OIDCIcon.vue";
import Logo from "../components/Logo.vue";
import TextInput from "../components/TextInput.vue";
import { authTypes } from "../constants.js";
import { useGlobalStore } from "../globalStore.js";
import { getToastOptions } from "../helpers.js";
import { storeToken } from "../tokenStorage.js";

const props = defineProps({ redirect: String });

const globalStore = useGlobalStore();
const router = useRouter();
const toast = useToast();

const username = ref("");
const password = ref("");
const totp = ref("");
const rememberMe = ref(false);

const showTotpModal = ref(false);
function logIn() {
getToken(username.value, password.value, totp.value)
.then((access_token) => {
Expand All @@ -82,23 +66,14 @@ function logIn() {
username.value = "";
password.value = "";
totp.value = "";

if (error.response?.status === 401) {
toast.add(
getToastOptions(
"Please check your credentials and try again.",
"Login Failed",
"error",
),
);
toast.add(getToastOptions("Please check your credentials and try again.", "Login Failed", "error"));
} else {
apiErrorHandler(error, toast);
}
});
}

// Redirect to home if authentication is disabled.
if (globalStore.config.authType === authTypes.none) {
router.push({ name: "home" });
}
</script>
</script>
1 change: 1 addition & 0 deletions server/auth/oidc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .oidc import OIDCAuth, GitHubAuth, GenericOIDCAuth, make_oidc_auth # noqa
207 changes: 207 additions & 0 deletions server/auth/oidc/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
from datetime import datetime, timedelta

import httpx
from fastapi import Depends, HTTPException, Request
from fastapi.security import OAuth2PasswordBearer
from jose import jwt

from helpers import get_env
from auth.models import Login, Token

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token", auto_error=False)


class OIDCAuth:
"""
Base OAuth2/OIDC auth. Shared JWT logic and authenticate().
Subclasses implement get_authorization_url() and handle_callback().
"""
JWT_ALGORITHM = "HS256"

def __init__(self) -> None:
self.client_id = get_env("OAUTH_CLIENT_ID", mandatory=True)
self.client_secret = get_env("OAUTH_CLIENT_SECRET", mandatory=True)
self.redirect_uri = get_env("OAUTH_REDIRECT_URI", mandatory=True)
self.secret_key = get_env("FLATNOTES_SECRET_KEY", mandatory=True)
self.session_expiry_days = get_env(
"FLATNOTES_SESSION_EXPIRY_DAYS", default=30, cast_int=True
)
allowed = get_env("OAUTH_ALLOWED_USERS", mandatory=False, default="")
self.allowed_users = (
{u.strip().lower() for u in allowed.split(",") if u.strip()}
if allowed else None
)

def _check_allowed(self, username: str):
if self.allowed_users and username.lower() not in self.allowed_users:
raise HTTPException(status_code=403, detail="User not allowed")

def login(self, data: Login) -> Token:
raise NotImplementedError("Use the OAuth flow.")

def authenticate(self, request: Request, token: str = Depends(oauth2_scheme)):
if token is None:
for name, value in request.cookies.items():
if (name == "token" or name.startswith("token_")) and value:
try:
self._validate_token(value)
token = value
break
except Exception:
continue
if not self._validate_token_bool(token):
raise HTTPException(status_code=401, headers={"WWW-Authenticate": "Bearer"})

def _validate_token(self, token: str) -> bool:
if token is None:
raise ValueError("No token")
payload = jwt.decode(token, self.secret_key, algorithms=[self.JWT_ALGORITHM])
if payload.get("sub") is None:
raise ValueError("Invalid subject")
return True

def _validate_token_bool(self, token: str) -> bool:
try:
return self._validate_token(token)
except Exception:
return False

def _create_access_token(self, data: dict) -> str:
to_encode = data.copy()
to_encode["exp"] = datetime.utcnow() + timedelta(days=self.session_expiry_days)
return jwt.encode(to_encode, self.secret_key, algorithm=self.JWT_ALGORITHM)

def get_authorization_url(self) -> str:
raise NotImplementedError

async def handle_callback(self, code: str) -> Token:
raise NotImplementedError


class GitHubAuth(OIDCAuth):
"""GitHub OAuth2 — no discovery URL needed."""

AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
TOKEN_URL = "https://github.com/login/oauth/access_token"
USER_URL = "https://api.github.com/user"

def get_authorization_url(self) -> str:
return (
f"{self.AUTHORIZE_URL}"
f"?client_id={self.client_id}"
f"&redirect_uri={self.redirect_uri}"
f"&scope=read:user"
)

async def handle_callback(self, code: str) -> Token:
async with httpx.AsyncClient() as client:
r = await client.post(
self.TOKEN_URL,
headers={"Accept": "application/json"},
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": self.redirect_uri,
},
)
r.raise_for_status()
github_token = r.json().get("access_token")
if not github_token:
raise HTTPException(status_code=401, detail="GitHub token exchange failed")

r = await client.get(
self.USER_URL,
headers={"Authorization": f"Bearer {github_token}", "Accept": "application/json"},
)
r.raise_for_status()
userinfo = r.json()

username = userinfo.get("login")
if not username:
raise HTTPException(status_code=400, detail="Could not retrieve GitHub username")
self._check_allowed(username)
return Token(access_token=self._create_access_token({"sub": username}))


class GenericOIDCAuth(OIDCAuth):
"""
Generic OIDC via discovery URL.
Set OIDC_DISCOVERY_URL to your provider's
/.well-known/openid-configuration endpoint.

Client credentials priority:
OIDC_CLIENT_ID > OAUTH_CLIENT_ID
OIDC_CLIENT_SECRET > OAUTH_CLIENT_SECRET
"""

def __init__(self) -> None:
super().__init__()
self.client_id = get_env("OIDC_CLIENT_ID", mandatory=True)
self.client_secret = get_env("OIDC_CLIENT_SECRET", mandatory=True)
self.redirect_uri = get_env("OIDC_REDIRECT_URI", mandatory=True)
self.discovery_url = get_env("OIDC_DISCOVERY_URL", mandatory=True)
self._metadata: dict | None = None

async def _get_metadata(self) -> dict:
if self._metadata is None:
async with httpx.AsyncClient() as client:
r = await client.get(self.discovery_url)
r.raise_for_status()
self._metadata = r.json()
return self._metadata

def get_authorization_url(self) -> str:
authorize_url = get_env("OIDC_AUTHORIZE_URL", mandatory=True)
scope = get_env("OIDC_SCOPE", mandatory=False, default="openid email profile")
return (
f"{authorize_url}"
f"?client_id={self.client_id}"
f"&redirect_uri={self.redirect_uri}"
f"&response_type=code"
f"&scope={scope}"
)

async def handle_callback(self, code: str) -> Token:
meta = await self._get_metadata()
async with httpx.AsyncClient() as client:
r = await client.post(
meta["token_endpoint"],
headers={"Accept": "application/json"},
data={
"grant_type": "authorization_code",
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": self.redirect_uri,
},
)
r.raise_for_status()
token_data = r.json()
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(status_code=401, detail="OIDC token exchange failed")

r = await client.get(
meta["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
r.raise_for_status()
userinfo = r.json()

subject = userinfo.get("preferred_username") or userinfo.get("email") or userinfo.get("sub")
if not subject:
raise HTTPException(status_code=400, detail="No usable subject in OIDC userinfo")
self._check_allowed(subject)
return Token(access_token=self._create_access_token({"sub": subject}))


def make_oidc_auth() -> OIDCAuth:
"""Factory — returns the right backend based on AUTH_PROVIDER."""
provider = get_env("AUTH_PROVIDER", mandatory=False, default="github").lower()
if provider == "github":
return GitHubAuth()
elif provider == "oidc":
return GenericOIDCAuth()
else:
raise ValueError(f"Unknown AUTH_PROVIDER '{provider}'. Use 'github' or 'oidc'.")
Loading