Add OIDC authentication

This commit is contained in:
itskovacs 2025-07-23 18:23:35 +02:00
parent 8af664b9e7
commit 03b294f138
9 changed files with 277 additions and 55 deletions

View File

@ -1 +1 @@
__version__ = "1.5.0" __version__ = "1.6.0"

View File

@ -17,6 +17,14 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_MINUTES: int = 1440 REFRESH_TOKEN_EXPIRE_MINUTES: int = 1440
REGISTER_ENABLE: bool = True
OIDC_PROTOCOL: str = "https"
OIDC_CLIENT_ID: str = ""
OIDC_CLIENT_SECRET: str = ""
OIDC_HOST: str = ""
OIDC_REALM: str = "master"
OIDC_REDIRECT_URI: str = ""
class Config: class Config:
env_file = "storage/config.yml" env_file = "storage/config.yml"

View File

@ -1,6 +1,7 @@
from typing import Annotated from typing import Annotated
import jwt import jwt
from authlib.integrations.httpx_client import OAuth2Client
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlmodel import Session from sqlmodel import Session
@ -9,7 +10,7 @@ from .config import settings
from .db.core import get_engine from .db.core import get_engine
from .models.models import User from .models.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") oauth_password_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_session(): def get_session():
@ -21,7 +22,7 @@ def get_session():
SessionDep = Annotated[Session, Depends(get_session)] SessionDep = Annotated[Session, Depends(get_session)]
def get_current_username(token: Annotated[str, Depends(oauth2_scheme)], session: SessionDep) -> str: def get_current_username(token: Annotated[str, Depends(oauth_password_scheme)], session: SessionDep) -> str:
try: try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username = payload.get("sub") username = payload.get("sub")
@ -34,3 +35,12 @@ def get_current_username(token: Annotated[str, Depends(oauth2_scheme)], session:
if not user: if not user:
raise HTTPException(status_code=401, detail="Invalid Token") raise HTTPException(status_code=401, detail="Invalid Token")
return user.username return user.username
def get_oidc_client():
return OAuth2Client(
client_id=settings.OIDC_CLIENT_ID,
client_secret=settings.OIDC_CLIENT_SECRET,
scope="openid",
redirect_uri=settings.OIDC_REDIRECT_URI,
)

View File

@ -27,6 +27,11 @@ def _prefix_assets_url(filename: str) -> str:
return base + filename return base + filename
class AuthParams(BaseModel):
oidc: str | None
register_enabled: bool
class TripItemStatusEnum(str, Enum): class TripItemStatusEnum(str, Enum):
PENDING = "pending" PENDING = "pending"
CONFIRMED = "booked" CONFIRMED = "booked"

View File

@ -1,16 +1,96 @@
import json
import jwt import jwt
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, HTTPException
from jwt.algorithms import RSAAlgorithm
from ..config import settings from ..config import settings
from ..db.core import init_user_data from ..db.core import init_user_data
from ..deps import SessionDep from ..deps import SessionDep, get_oidc_client
from ..models.models import LoginRegisterModel, Token, User from ..models.models import AuthParams, LoginRegisterModel, Token, User
from ..security import (create_access_token, create_tokens, hash_password, from ..security import (create_access_token, create_tokens, hash_password,
verify_password) verify_password)
from ..utils.utils import generate_filename, httpx_get
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.get("/params", response_model=AuthParams)
async def auth_params() -> AuthParams:
data = {"oidc": None, "register_enabled": settings.REGISTER_ENABLE}
if settings.OIDC_HOST and settings.OIDC_CLIENT_ID and settings.OIDC_CLIENT_SECRET:
oidc_complete_url = f"{settings.OIDC_PROTOCOL}://{settings.OIDC_HOST}/realms/{settings.OIDC_REALM}/protocol/openid-connect/auth?client_id={settings.OIDC_CLIENT_ID}&redirect_uri={settings.OIDC_REDIRECT_URI}&response_type=code&scope=openid"
data["oidc"] = oidc_complete_url
return data
@router.post("/oidc/login", response_model=Token)
async def oidc_login(session: SessionDep, code: str = Body(..., embed=True)) -> Token:
if settings.AUTH_METHOD != "oidc":
raise HTTPException(status_code=400, detail="Bad request")
try:
oidc_client = get_oidc_client()
token = oidc_client.fetch_token(
f"{settings.OIDC_PROTOCOL}://{settings.OIDC_HOST}/realms/{settings.OIDC_REALM}/protocol/openid-connect/token",
grant_type="authorization_code",
code=code,
)
except Exception:
raise HTTPException(status_code=401, detail="OIDC login failed")
id_token = token.get("id_token")
alg = jwt.get_unverified_header(id_token).get("alg")
match alg:
case "HS256":
decoded = jwt.decode(
id_token,
settings.OIDC_CLIENT_SECRET,
algorithms=alg,
audience=settings.OIDC_CLIENT_ID,
)
case "RS256":
config = await httpx_get(
f"{settings.OIDC_PROTOCOL}://{settings.OIDC_HOST}/realms/{settings.OIDC_REALM}/.well-known/openid-configuration"
)
jwks_uri = config.get("jwks_uri")
jwks = await httpx_get(jwks_uri)
keys = jwks.get("keys")
for key in keys:
try:
pk = RSAAlgorithm.from_jwk(json.dumps(key))
decoded = jwt.decode(
id_token,
key=pk,
algorithms=alg,
audience=settings.OIDC_CLIENT_ID,
issuer=f"{settings.OIDC_PROTOCOL}://{settings.OIDC_HOST}/realms/{settings.OIDC_REALM}",
)
break
except Exception:
continue
case _:
raise HTTPException(status_code=500, detail="OIDC login failed, algorithm not handled")
if not decoded:
raise HTTPException(status_code=401, detail="Invalid ID token")
username = decoded.get("preferred_username")
user = session.get(User, username)
if not user:
# TODO: password is non-null, we must init the pw with something, the model is not made for OIDC
user = User(username=username, password=hash_password(generate_filename("find-something-else")))
session.add(user)
session.commit()
init_user_data(session, username)
return create_tokens(data={"sub": username})
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
def login(req: LoginRegisterModel, session: SessionDep) -> Token: def login(req: LoginRegisterModel, session: SessionDep) -> Token:
db_user = session.get(User, req.username) db_user = session.get(User, req.username)

View File

@ -36,6 +36,7 @@ def remove_image(path: str):
try: try:
fpath = Path(assets_folder_path() / path) fpath = Path(assets_folder_path() / path)
if not fpath.exists(): if not fpath.exists():
# Skips missing file
return return
fpath.unlink() fpath.unlink()
except OSError as exc: except OSError as exc:
@ -51,6 +52,23 @@ def parse_str_or_date_to_date(cdate: str | date) -> date:
return cdate return cdate
async def httpx_get(link: str) -> str:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Referer": link,
}
try:
async with httpx.AsyncClient(follow_redirects=True, headers=headers, timeout=5) as client:
response = await client.get(link)
response.raise_for_status()
return response.json()
except Exception:
raise HTTPException(status_code=400, detail="Bad Request")
async def download_file(link: str, raise_on_error: bool = False) -> str: async def download_file(link: str, raise_on_error: bool = False) -> str:
headers = { headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",

View File

@ -18,6 +18,13 @@
</div> </div>
} }
@defer () {
@if (authParams?.oidc) {
<div class="mt-8 text-center">
<p-button variant="outlined" severity="primary" size="large" icon="pi pi-key" label="Sign in"
(click)="authenticate()" />
</div>
} @else {
<div pFocusTrap class="mt-4" [formGroup]="authForm"> <div pFocusTrap class="mt-4" [formGroup]="authForm">
<p-floatlabel variant="in"> <p-floatlabel variant="in">
<input #username pInputText id="username" formControlName="username" autocorrect="off" autocapitalize="none" <input #username pInputText id="username" formControlName="username" autocorrect="off" autocapitalize="none"
@ -56,6 +63,7 @@
</div> </div>
</div> </div>
@if (authParams?.register_enabled) {
<hr class="my-6 border-gray-300 w-full" /> <hr class="my-6 border-gray-300 w-full" />
@if (isRegistering) { @if (isRegistering) {
<p class="mt-8"> <p class="mt-8">
@ -70,6 +78,19 @@
an account</a> an account</a>
</p> </p>
} }
}
}
} @placeholder (minimum 0.4s) {
<div class="mt-4">
<p-skeleton width="100%" height="3.5rem" />
</div>
<div class="mt-4">
<p-skeleton width="100%" height="3.5rem" />
</div>
<div class="mt-4 flex justify-end">
<p-skeleton width="80px" height="2.5rem" />
</div>
}
</div> </div>
<div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-sm flex flex-col items-center gap-2"> <div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-sm flex flex-col items-center gap-2">
@ -85,7 +106,7 @@
Welcome to TRIP Welcome to TRIP
</div> </div>
<div class="mt-6 text-lg tracking-tight leading-6 text-gray-400"> <div class="mt-6 text-lg tracking-tight leading-6 text-gray-400">
Tourism and Recreation Interest Points. Tourism and Recreational Interest Points.
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,23 +1,24 @@
import { Component } from '@angular/core'; import { Component } from "@angular/core";
import { FloatLabelModule } from 'primeng/floatlabel'; import { FloatLabelModule } from "primeng/floatlabel";
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
Validators, Validators,
} from '@angular/forms'; } from "@angular/forms";
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from "@angular/router";
import { InputTextModule } from 'primeng/inputtext'; import { InputTextModule } from "primeng/inputtext";
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from "primeng/button";
import { FocusTrapModule } from 'primeng/focustrap'; import { FocusTrapModule } from "primeng/focustrap";
import { AuthService } from '../../services/auth.service'; import { AuthParams, AuthService, Token } from "../../services/auth.service";
import { MessageModule } from 'primeng/message'; import { MessageModule } from "primeng/message";
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from "@angular/common/http";
import { SkeletonModule } from "primeng/skeleton";
@Component({ @Component({
selector: 'app-auth', selector: "app-auth",
standalone: true, standalone: true,
imports: [ imports: [
FloatLabelModule, FloatLabelModule,
@ -25,16 +26,18 @@ import { HttpErrorResponse } from '@angular/common/http';
ButtonModule, ButtonModule,
FormsModule, FormsModule,
InputTextModule, InputTextModule,
SkeletonModule,
FocusTrapModule, FocusTrapModule,
MessageModule, MessageModule,
], ],
templateUrl: './auth.component.html', templateUrl: "./auth.component.html",
styleUrl: './auth.component.scss', styleUrl: "./auth.component.scss",
}) })
export class AuthComponent { export class AuthComponent {
private redirectURL: string; private redirectURL: string;
authParams: AuthParams | undefined;
authForm: FormGroup; authForm: FormGroup;
error: string = ''; error: string = "";
isRegistering: boolean = false; isRegistering: boolean = false;
constructor( constructor(
@ -43,12 +46,31 @@ export class AuthComponent {
private route: ActivatedRoute, private route: ActivatedRoute,
private fb: FormBuilder, private fb: FormBuilder,
) { ) {
this.route.queryParams.subscribe((params) => {
const code = params["code"];
if (code) {
this.authService.oidcLogin(code).subscribe({
next: (data) => {
if (!data.access_token) {
this.error = "Authentication failed";
return;
}
this.router.navigateByUrl(this.redirectURL);
},
});
}
});
this.authService.authParams().subscribe({
next: (params) => (this.authParams = params),
});
this.redirectURL = this.redirectURL =
this.route.snapshot.queryParams['redirectURL'] || '/home'; this.route.snapshot.queryParams["redirectURL"] || "/home";
this.authForm = this.fb.group({ this.authForm = this.fb.group({
username: ['', { validators: Validators.required }], username: ["", { validators: Validators.required }],
password: ['', { validators: Validators.required }], password: ["", { validators: Validators.required }],
}); });
} }
@ -58,7 +80,7 @@ export class AuthComponent {
} }
register(): void { register(): void {
this.error = ''; this.error = "";
if (this.authForm.valid) { if (this.authForm.valid) {
this.authService.register(this.authForm.value).subscribe({ this.authService.register(this.authForm.value).subscribe({
next: () => { next: () => {
@ -73,17 +95,22 @@ export class AuthComponent {
} }
authenticate(): void { authenticate(): void {
this.error = ''; this.error = "";
if (this.authForm.valid) { if (this.authParams?.oidc) {
this.authService.login(this.authForm.value).subscribe({ window.location.replace(encodeURI(this.authParams.oidc));
next: () => {
this.router.navigateByUrl(this.redirectURL);
},
error: (err: HttpErrorResponse) => {
this.authForm.reset();
this.error = err.error.detail;
},
});
} }
this.authService.login(this.authForm.value).subscribe({
next: (data) => {
if (!data.access_token) {
this.error = "Authentication failed";
return;
}
this.router.navigateByUrl(this.redirectURL);
},
error: () => {
this.authForm.reset();
},
});
} }
} }

View File

@ -11,6 +11,11 @@ export interface Token {
access_token: string; access_token: string;
} }
export interface AuthParams {
register_enabled: boolean;
oidc?: string;
}
const JWT_TOKEN = "TRIP_AT"; const JWT_TOKEN = "TRIP_AT";
const REFRESH_TOKEN = "TRIP_RT"; const REFRESH_TOKEN = "TRIP_RT";
const JWT_USER = "TRIP_USER"; const JWT_USER = "TRIP_USER";
@ -23,7 +28,7 @@ export class AuthService {
private httpClient: HttpClient, private httpClient: HttpClient,
private router: Router, private router: Router,
private apiService: ApiService, private apiService: ApiService,
private utilsService: UtilsService private utilsService: UtilsService,
) { ) {
this.apiBaseUrl = this.apiService.apiBaseUrl; this.apiBaseUrl = this.apiService.apiBaseUrl;
} }
@ -52,6 +57,10 @@ export class AuthService {
return localStorage.getItem(REFRESH_TOKEN) ?? ""; return localStorage.getItem(REFRESH_TOKEN) ?? "";
} }
authParams(): Observable<AuthParams> {
return this.httpClient.get<AuthParams>(this.apiBaseUrl + "/auth/params");
}
storeTokens(tokens: Token): void { storeTokens(tokens: Token): void {
this.accessToken = tokens.access_token; this.accessToken = tokens.access_token;
this.refreshToken = tokens.refresh_token; this.refreshToken = tokens.refresh_token;
@ -71,26 +80,46 @@ export class AuthService {
.pipe( .pipe(
tap((tokens: Token) => { tap((tokens: Token) => {
this.accessToken = tokens.access_token; this.accessToken = tokens.access_token;
}) }),
); );
} }
login(authForm: { username: string; password: string }): Observable<Token> { login(authForm: { username: string; password: string }): Observable<Token> {
return this.httpClient.post<Token>(this.apiBaseUrl + "/auth/login", authForm).pipe( return this.httpClient
tap((tokens: Token) => { .post<Token>(this.apiBaseUrl + "/auth/login", authForm)
this.loggedUser = authForm.username; .pipe(
this.storeTokens(tokens); tap((tokens: Token) => {
}) this.loggedUser = authForm.username;
); this.storeTokens(tokens);
}),
);
} }
register(authForm: { username: string; password: string }): Observable<Token> { register(authForm: {
return this.httpClient.post<Token>(this.apiBaseUrl + "/auth/register", authForm).pipe( username: string;
tap((tokens: Token) => { password: string;
this.loggedUser = authForm.username; }): Observable<Token> {
this.storeTokens(tokens); return this.httpClient
}) .post<Token>(this.apiBaseUrl + "/auth/register", authForm)
); .pipe(
tap((tokens: Token) => {
this.loggedUser = authForm.username;
this.storeTokens(tokens);
}),
);
}
oidcLogin(code: string): Observable<Token> {
return this.httpClient
.post<Token>(this.apiBaseUrl + "/auth/oidc/login", { code })
.pipe(
tap((data: any) => {
if (data.access_token && data.refresh_token) {
this.loggedUser = this._getTokenUsername(data.access_token);
this.storeTokens(data);
}
}),
);
} }
logout(custom_msg: string = "", is_error = false): void { logout(custom_msg: string = "", is_error = false): void {
@ -99,7 +128,11 @@ export class AuthService {
if (custom_msg) { if (custom_msg) {
if (is_error) { if (is_error) {
this.utilsService.toast("error", "You must be authenticated", custom_msg); this.utilsService.toast(
"error",
"You must be authenticated",
custom_msg,
);
} else { } else {
this.utilsService.toast("success", "Success", custom_msg); this.utilsService.toast("success", "Success", custom_msg);
} }
@ -135,19 +168,25 @@ export class AuthService {
private _b64DecodeUnicode(str: any): string { private _b64DecodeUnicode(str: any): string {
return decodeURIComponent( return decodeURIComponent(
Array.prototype.map Array.prototype.map
.call(this._b64decode(str), (c: any) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) .call(
.join("") this._b64decode(str),
(c: any) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2),
)
.join(""),
); );
} }
private _b64decode(str: string): string { private _b64decode(str: string): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
let output = ""; let output = "";
str = String(str).replace(/=+$/, ""); str = String(str).replace(/=+$/, "");
if (str.length % 4 === 1) { if (str.length % 4 === 1) {
throw new Error("'atob' failed: The string to be decoded is not correctly encoded."); throw new Error(
"'atob' failed: The string to be decoded is not correctly encoded.",
);
} }
/* eslint-disable */ /* eslint-disable */
@ -186,6 +225,20 @@ export class AuthService {
return this._b64DecodeUnicode(output); return this._b64DecodeUnicode(output);
} }
private _getTokenUsername(token: string): string {
const decodedToken = this._decodeToken(token);
if (decodedToken === null) {
return "";
}
if (!decodedToken.hasOwnProperty("sub")) {
return "";
}
return decodedToken.sub;
}
private _decodeToken(token: string): any { private _decodeToken(token: string): any {
if (!token) { if (!token) {
return null; return null;