Skip to content

Commit 03b294f

Browse files
committed
✨ Add OIDC authentication
1 parent 8af664b commit 03b294f

File tree

9 files changed

+277
-55
lines changed

9 files changed

+277
-55
lines changed

backend/trip/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.5.0"
1+
__version__ = "1.6.0"

backend/trip/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ class Settings(BaseSettings):
1717
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
1818
REFRESH_TOKEN_EXPIRE_MINUTES: int = 1440
1919

20+
REGISTER_ENABLE: bool = True
21+
OIDC_PROTOCOL: str = "https"
22+
OIDC_CLIENT_ID: str = ""
23+
OIDC_CLIENT_SECRET: str = ""
24+
OIDC_HOST: str = ""
25+
OIDC_REALM: str = "master"
26+
OIDC_REDIRECT_URI: str = ""
27+
2028
class Config:
2129
env_file = "storage/config.yml"
2230

backend/trip/deps.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Annotated
22

33
import jwt
4+
from authlib.integrations.httpx_client import OAuth2Client
45
from fastapi import Depends, HTTPException
56
from fastapi.security import OAuth2PasswordBearer
67
from sqlmodel import Session
@@ -9,7 +10,7 @@
910
from .db.core import get_engine
1011
from .models.models import User
1112

12-
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
13+
oauth_password_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
1314

1415

1516
def get_session():
@@ -21,7 +22,7 @@ def get_session():
2122
SessionDep = Annotated[Session, Depends(get_session)]
2223

2324

24-
def get_current_username(token: Annotated[str, Depends(oauth2_scheme)], session: SessionDep) -> str:
25+
def get_current_username(token: Annotated[str, Depends(oauth_password_scheme)], session: SessionDep) -> str:
2526
try:
2627
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
2728
username = payload.get("sub")
@@ -34,3 +35,12 @@ def get_current_username(token: Annotated[str, Depends(oauth2_scheme)], session:
3435
if not user:
3536
raise HTTPException(status_code=401, detail="Invalid Token")
3637
return user.username
38+
39+
40+
def get_oidc_client():
41+
return OAuth2Client(
42+
client_id=settings.OIDC_CLIENT_ID,
43+
client_secret=settings.OIDC_CLIENT_SECRET,
44+
scope="openid",
45+
redirect_uri=settings.OIDC_REDIRECT_URI,
46+
)

backend/trip/models/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ def _prefix_assets_url(filename: str) -> str:
2727
return base + filename
2828

2929

30+
class AuthParams(BaseModel):
31+
oidc: str | None
32+
register_enabled: bool
33+
34+
3035
class TripItemStatusEnum(str, Enum):
3136
PENDING = "pending"
3237
CONFIRMED = "booked"

backend/trip/routers/auth.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,96 @@
1+
import json
2+
13
import jwt
24
from fastapi import APIRouter, Body, HTTPException
5+
from jwt.algorithms import RSAAlgorithm
36

47
from ..config import settings
58
from ..db.core import init_user_data
6-
from ..deps import SessionDep
7-
from ..models.models import LoginRegisterModel, Token, User
9+
from ..deps import SessionDep, get_oidc_client
10+
from ..models.models import AuthParams, LoginRegisterModel, Token, User
811
from ..security import (create_access_token, create_tokens, hash_password,
912
verify_password)
13+
from ..utils.utils import generate_filename, httpx_get
1014

1115
router = APIRouter(prefix="/api/auth", tags=["auth"])
1216

1317

18+
@router.get("/params", response_model=AuthParams)
19+
async def auth_params() -> AuthParams:
20+
data = {"oidc": None, "register_enabled": settings.REGISTER_ENABLE}
21+
22+
if settings.OIDC_HOST and settings.OIDC_CLIENT_ID and settings.OIDC_CLIENT_SECRET:
23+
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"
24+
data["oidc"] = oidc_complete_url
25+
26+
return data
27+
28+
29+
@router.post("/oidc/login", response_model=Token)
30+
async def oidc_login(session: SessionDep, code: str = Body(..., embed=True)) -> Token:
31+
if settings.AUTH_METHOD != "oidc":
32+
raise HTTPException(status_code=400, detail="Bad request")
33+
34+
try:
35+
oidc_client = get_oidc_client()
36+
token = oidc_client.fetch_token(
37+
f"{settings.OIDC_PROTOCOL}://{settings.OIDC_HOST}/realms/{settings.OIDC_REALM}/protocol/openid-connect/token",
38+
grant_type="authorization_code",
39+
code=code,
40+
)
41+
except Exception:
42+
raise HTTPException(status_code=401, detail="OIDC login failed")
43+
44+
id_token = token.get("id_token")
45+
alg = jwt.get_unverified_header(id_token).get("alg")
46+
47+
match alg:
48+
case "HS256":
49+
decoded = jwt.decode(
50+
id_token,
51+
settings.OIDC_CLIENT_SECRET,
52+
algorithms=alg,
53+
audience=settings.OIDC_CLIENT_ID,
54+
)
55+
case "RS256":
56+
config = await httpx_get(
57+
f"{settings.OIDC_PROTOCOL}://{settings.OIDC_HOST}/realms/{settings.OIDC_REALM}/.well-known/openid-configuration"
58+
)
59+
jwks_uri = config.get("jwks_uri")
60+
jwks = await httpx_get(jwks_uri)
61+
keys = jwks.get("keys")
62+
63+
for key in keys:
64+
try:
65+
pk = RSAAlgorithm.from_jwk(json.dumps(key))
66+
decoded = jwt.decode(
67+
id_token,
68+
key=pk,
69+
algorithms=alg,
70+
audience=settings.OIDC_CLIENT_ID,
71+
issuer=f"{settings.OIDC_PROTOCOL}://{settings.OIDC_HOST}/realms/{settings.OIDC_REALM}",
72+
)
73+
break
74+
except Exception:
75+
continue
76+
case _:
77+
raise HTTPException(status_code=500, detail="OIDC login failed, algorithm not handled")
78+
79+
if not decoded:
80+
raise HTTPException(status_code=401, detail="Invalid ID token")
81+
82+
username = decoded.get("preferred_username")
83+
user = session.get(User, username)
84+
if not user:
85+
# TODO: password is non-null, we must init the pw with something, the model is not made for OIDC
86+
user = User(username=username, password=hash_password(generate_filename("find-something-else")))
87+
session.add(user)
88+
session.commit()
89+
init_user_data(session, username)
90+
91+
return create_tokens(data={"sub": username})
92+
93+
1494
@router.post("/login", response_model=Token)
1595
def login(req: LoginRegisterModel, session: SessionDep) -> Token:
1696
db_user = session.get(User, req.username)

backend/trip/utils/utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def remove_image(path: str):
3636
try:
3737
fpath = Path(assets_folder_path() / path)
3838
if not fpath.exists():
39+
# Skips missing file
3940
return
4041
fpath.unlink()
4142
except OSError as exc:
@@ -51,6 +52,23 @@ def parse_str_or_date_to_date(cdate: str | date) -> date:
5152
return cdate
5253

5354

55+
async def httpx_get(link: str) -> str:
56+
headers = {
57+
"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",
58+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
59+
"Accept-Language": "en-US,en;q=0.5",
60+
"Referer": link,
61+
}
62+
63+
try:
64+
async with httpx.AsyncClient(follow_redirects=True, headers=headers, timeout=5) as client:
65+
response = await client.get(link)
66+
response.raise_for_status()
67+
return response.json()
68+
except Exception:
69+
raise HTTPException(status_code=400, detail="Bad Request")
70+
71+
5472
async def download_file(link: str, raise_on_error: bool = False) -> str:
5573
headers = {
5674
"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",

src/src/app/components/auth/auth.component.html

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
</div>
1919
}
2020

21+
@defer () {
22+
@if (authParams?.oidc) {
23+
<div class="mt-8 text-center">
24+
<p-button variant="outlined" severity="primary" size="large" icon="pi pi-key" label="Sign in"
25+
(click)="authenticate()" />
26+
</div>
27+
} @else {
2128
<div pFocusTrap class="mt-4" [formGroup]="authForm">
2229
<p-floatlabel variant="in">
2330
<input #username pInputText id="username" formControlName="username" autocorrect="off" autocapitalize="none"
@@ -56,6 +63,7 @@
5663
</div>
5764
</div>
5865

66+
@if (authParams?.register_enabled) {
5967
<hr class="my-6 border-gray-300 w-full" />
6068
@if (isRegistering) {
6169
<p class="mt-8">
@@ -70,6 +78,19 @@
7078
an account</a>
7179
</p>
7280
}
81+
}
82+
}
83+
} @placeholder (minimum 0.4s) {
84+
<div class="mt-4">
85+
<p-skeleton width="100%" height="3.5rem" />
86+
</div>
87+
<div class="mt-4">
88+
<p-skeleton width="100%" height="3.5rem" />
89+
</div>
90+
<div class="mt-4 flex justify-end">
91+
<p-skeleton width="80px" height="2.5rem" />
92+
</div>
93+
}
7394
</div>
7495

7596
<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 @@
85106
Welcome to TRIP
86107
</div>
87108
<div class="mt-6 text-lg tracking-tight leading-6 text-gray-400">
88-
Tourism and Recreation Interest Points.
109+
Tourism and Recreational Interest Points.
89110
</div>
90111
</div>
91112
</div>
Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,43 @@
1-
import { Component } from '@angular/core';
1+
import { Component } from "@angular/core";
22

3-
import { FloatLabelModule } from 'primeng/floatlabel';
3+
import { FloatLabelModule } from "primeng/floatlabel";
44
import {
55
FormBuilder,
66
FormGroup,
77
FormsModule,
88
ReactiveFormsModule,
99
Validators,
10-
} from '@angular/forms';
11-
import { ActivatedRoute, Router } from '@angular/router';
12-
import { InputTextModule } from 'primeng/inputtext';
13-
import { ButtonModule } from 'primeng/button';
14-
import { FocusTrapModule } from 'primeng/focustrap';
15-
import { AuthService } from '../../services/auth.service';
16-
import { MessageModule } from 'primeng/message';
17-
import { HttpErrorResponse } from '@angular/common/http';
10+
} from "@angular/forms";
11+
import { ActivatedRoute, Router } from "@angular/router";
12+
import { InputTextModule } from "primeng/inputtext";
13+
import { ButtonModule } from "primeng/button";
14+
import { FocusTrapModule } from "primeng/focustrap";
15+
import { AuthParams, AuthService, Token } from "../../services/auth.service";
16+
import { MessageModule } from "primeng/message";
17+
import { HttpErrorResponse } from "@angular/common/http";
18+
import { SkeletonModule } from "primeng/skeleton";
1819

1920
@Component({
20-
selector: 'app-auth',
21+
selector: "app-auth",
2122
standalone: true,
2223
imports: [
2324
FloatLabelModule,
2425
ReactiveFormsModule,
2526
ButtonModule,
2627
FormsModule,
2728
InputTextModule,
29+
SkeletonModule,
2830
FocusTrapModule,
2931
MessageModule,
3032
],
31-
templateUrl: './auth.component.html',
32-
styleUrl: './auth.component.scss',
33+
templateUrl: "./auth.component.html",
34+
styleUrl: "./auth.component.scss",
3335
})
3436
export class AuthComponent {
3537
private redirectURL: string;
38+
authParams: AuthParams | undefined;
3639
authForm: FormGroup;
37-
error: string = '';
40+
error: string = "";
3841
isRegistering: boolean = false;
3942

4043
constructor(
@@ -43,12 +46,31 @@ export class AuthComponent {
4346
private route: ActivatedRoute,
4447
private fb: FormBuilder,
4548
) {
49+
this.route.queryParams.subscribe((params) => {
50+
const code = params["code"];
51+
if (code) {
52+
this.authService.oidcLogin(code).subscribe({
53+
next: (data) => {
54+
if (!data.access_token) {
55+
this.error = "Authentication failed";
56+
return;
57+
}
58+
this.router.navigateByUrl(this.redirectURL);
59+
},
60+
});
61+
}
62+
});
63+
64+
this.authService.authParams().subscribe({
65+
next: (params) => (this.authParams = params),
66+
});
67+
4668
this.redirectURL =
47-
this.route.snapshot.queryParams['redirectURL'] || '/home';
69+
this.route.snapshot.queryParams["redirectURL"] || "/home";
4870

4971
this.authForm = this.fb.group({
50-
username: ['', { validators: Validators.required }],
51-
password: ['', { validators: Validators.required }],
72+
username: ["", { validators: Validators.required }],
73+
password: ["", { validators: Validators.required }],
5274
});
5375
}
5476

@@ -58,7 +80,7 @@ export class AuthComponent {
5880
}
5981

6082
register(): void {
61-
this.error = '';
83+
this.error = "";
6284
if (this.authForm.valid) {
6385
this.authService.register(this.authForm.value).subscribe({
6486
next: () => {
@@ -73,17 +95,22 @@ export class AuthComponent {
7395
}
7496

7597
authenticate(): void {
76-
this.error = '';
77-
if (this.authForm.valid) {
78-
this.authService.login(this.authForm.value).subscribe({
79-
next: () => {
80-
this.router.navigateByUrl(this.redirectURL);
81-
},
82-
error: (err: HttpErrorResponse) => {
83-
this.authForm.reset();
84-
this.error = err.error.detail;
85-
},
86-
});
98+
this.error = "";
99+
if (this.authParams?.oidc) {
100+
window.location.replace(encodeURI(this.authParams.oidc));
87101
}
102+
103+
this.authService.login(this.authForm.value).subscribe({
104+
next: (data) => {
105+
if (!data.access_token) {
106+
this.error = "Authentication failed";
107+
return;
108+
}
109+
this.router.navigateByUrl(this.redirectURL);
110+
},
111+
error: () => {
112+
this.authForm.reset();
113+
},
114+
});
88115
}
89116
}

0 commit comments

Comments
 (0)