Skip to content

Commit 5a21258

Browse files
committed
Enable auth with keycloak
1 parent cf0e5fe commit 5a21258

19 files changed

+418
-94
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# fastapi-react-postgres-keycloak
22

3+
To launch all the services just execute:
4+
5+
```bash
6+
docker-compose up -d
7+
```
8+
39
## Keycloak installation
410

511
Create a `.env` file based on the `.env.example` file.

backend/app/main.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Main module."""
2-
from fastapi import FastAPI, Request
2+
from fastapi import Depends, FastAPI, Request
33
from fastapi.middleware.cors import CORSMiddleware
44
from fastapi.responses import Response
55
from simber import Logger
66
import uvicorn
77

8-
from app.router import targets
8+
from app.router import auth, targets
9+
from app.service.keycloak import verify_token
910

1011
LOG_FORMAT = "{levelname} [{filename}:{lineno}]:"
1112
logger = Logger(__name__, log_path="/logs/api.log")
@@ -47,11 +48,11 @@ async def root() -> Response:
4748

4849
app.include_router(
4950
targets.router,
50-
prefix="/api",
51+
prefix="/api/targets",
5152
tags=["targets"],
52-
# dependencies=[Depends(verify_token)],
53+
dependencies=[Depends(verify_token)],
5354
)
54-
# app.include_router(auth.router, prefix="/api", tags=["auth"])
55+
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
5556

5657
if __name__ == "__main__":
5758
uvicorn.run("main:app", host="0.0.0.0", reload=True, port=8888) # nosec

backend/app/router/auth.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,30 @@
33

44
from fastapi import APIRouter, Depends
55
from fastapi.security import OAuth2PasswordRequestForm
6+
from pydantic import BaseModel
67

7-
from app.service.keycloak import authenticate_user
8+
from app.service.keycloak import authenticate_user, logout, refresh_token
89

910
router = APIRouter()
1011

1112

13+
class Token(BaseModel):
14+
token: str
15+
16+
1217
@router.post("/token")
1318
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> tp.Dict[str, str]:
1419
"""Login user."""
1520
token = await authenticate_user(form_data.username, form_data.password)
1621
return token
22+
23+
24+
@router.post("/refresh")
25+
async def refresh(token: Token) -> tp.Dict[str, str]:
26+
new_token = await refresh_token(token.token)
27+
return new_token
28+
29+
30+
@router.post("/logout")
31+
async def logout_user(token: Token) -> None:
32+
await logout(token.token)

backend/app/router/targets.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
router = APIRouter()
1111

1212

13-
@router.post("/targets", response_model=schemas.Target)
13+
@router.post("", response_model=schemas.Target)
1414
def create_target(
1515
target: schemas.TargetIn, db: Session = Depends(get_db)
1616
) -> schemas.Target:
@@ -19,7 +19,7 @@ def create_target(
1919

2020

2121
@router.get(
22-
"/targets",
22+
"",
2323
response_model=tp.List[schemas.Target],
2424
response_model_include={"id", "first_name", "last_name"},
2525
)
@@ -28,19 +28,19 @@ def read_targets(db: Session = Depends(get_db)) -> tp.List[schemas.Target]:
2828
return crud.get_targets(db)
2929

3030

31-
@router.get("/targets/{target_id}", response_model=schemas.Target)
31+
@router.get("/{target_id}", response_model=schemas.Target)
3232
def read_target(target_id: int, db: Session = Depends(get_db)) -> schemas.Target:
3333
"""Get a specific target."""
3434
return crud.get_target(db, target_id)
3535

3636

37-
@router.delete("/targets/{target_id}", response_model=schemas.Target)
37+
@router.delete("/{target_id}", response_model=schemas.Target)
3838
def delete_target(target_id: int, db: Session = Depends(get_db)) -> schemas.Target:
3939
"""Delete a target."""
4040
return crud.delete_target(db, target_id)
4141

4242

43-
@router.put("/targets/{target_id}", response_model=schemas.Target)
43+
@router.put("/{target_id}", response_model=schemas.Target)
4444
def edit_target(
4545
target_id: int, target: schemas.TargetIn, db: Session = Depends(get_db)
4646
) -> schemas.Target:
@@ -54,7 +54,7 @@ def read_pictures(db: Session = Depends(get_db)) -> tp.List[schemas.Picture]:
5454
return crud.get_pictures(db)
5555

5656

57-
@router.post("/targets/{target_id}/pictures", response_model=schemas.Picture)
57+
@router.post("/{target_id}/pictures", response_model=schemas.Picture)
5858
def create_picture_for_target(
5959
target_id: int, picture: schemas.PictureCreate, db: Session = Depends(get_db)
6060
) -> schemas.Picture:

backend/app/service/keycloak.py

+14
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,17 @@ async def verify_token(token: str = Depends(oauth2_scheme)) -> tp.Dict[str, str]
5959
raise HTTPException(
6060
status_code=401, detail=str(error), headers={"WWW-Authenticate": "Bearer"}
6161
) from error
62+
63+
64+
async def refresh_token(token: str) -> tp.Dict[str, str]:
65+
try:
66+
return keycloak_openid.refresh_token(token)
67+
except (KeycloakGetError) as error:
68+
raise HTTPException(status_code=401, detail=str(error)) from error
69+
70+
71+
async def logout(token: str) -> tp.Dict[str, str]:
72+
try:
73+
return keycloak_openid.logout(token)
74+
except (KeycloakGetError) as error:
75+
raise HTTPException(status_code=401, detail=str(error)) from error

frontend/app/public/favicon.ico

15 KB
Binary file not shown.

frontend/app/public/index.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33

44
<head>
55
<meta charset="utf-8" />
6-
<link rel="icon" href="%PUBLIC_URL%/logo.png" />
6+
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
77
<meta name="viewport" content="width=device-width, initial-scale=1" />
88
<meta name="theme-color" content="#000000" />
99
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
1010
<link rel="stylesheet" href="%PUBLIC_URL%/css/bootstrap.css" />
11-
<title>My Project</title>
11+
<title>Targets</title>
1212
</head>
1313

1414
<body>

frontend/app/public/manifest.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "Create React App Sample",
44
"icons": [
55
{
6-
"src": "logo.png",
6+
"src": "favicon.ico",
77
"sizes": "64x64 32x32 24x24 16x16",
88
"type": "image/x-icon"
99
}

frontend/app/src/App.tsx

+22-13
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,39 @@
1-
import React, { FC } from "react";
1+
import React, { createContext, FC, useState } from "react";
22
import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom";
33

4-
import {
5-
Home,
6-
TargetInfo,
7-
TargetSearch,
8-
NavigationBar,
9-
TargetCreate,
10-
} from "./components/public";
4+
import { Home, NavigationBar, Login } from "./components/public";
5+
import { TargetInfo, TargetSearch, TargetCreate } from "./components/private";
6+
import { PrivateRoute } from "./PrivateRoute";
7+
import { isAuthenticated, peridodicRefreshTokenCheck } from "./utils/Auth";
8+
9+
export const AuthContext = createContext({
10+
authenticated: false,
11+
setAuthenticated: (auth: boolean) => {},
12+
});
1113

1214
export const App: FC = () => {
15+
const [authenticated, setAuthenticated] = useState<boolean>(
16+
isAuthenticated()
17+
);
18+
19+
peridodicRefreshTokenCheck(60);
20+
1321
return (
14-
<>
22+
<AuthContext.Provider value={{ authenticated, setAuthenticated }}>
1523
<BrowserRouter basename="/">
1624
<NavigationBar />
1725
<Switch>
1826
<Route exact path="/" component={Home} />
19-
<Route exact path="/targets" component={TargetSearch} />
20-
<Route exact path="/targets/create" component={TargetCreate} />
21-
<Route path="/targets/:id" component={TargetInfo} />
27+
<Route exact path="/login" component={Login} />
28+
<PrivateRoute exact path="/targets" component={TargetSearch} />
29+
<PrivateRoute exact path="/targets/create" component={TargetCreate} />
30+
<PrivateRoute path="/targets/:id" component={TargetInfo} />
2231
<Route>
2332
<Redirect to="/" />
2433
</Route>
2534
</Switch>
2635
</BrowserRouter>
27-
</>
36+
</AuthContext.Provider>
2837
);
2938
};
3039

frontend/app/src/PrivateRoute.tsx

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Redirect, Route } from "react-router";
2+
import { isAuthenticated } from "./utils/Auth";
3+
4+
export const PrivateRoute = ({ component: Component, ...rest }: any) => {
5+
return (
6+
<Route
7+
{...rest}
8+
render={({ location }) =>
9+
isAuthenticated() ? (
10+
<Component {...rest} />
11+
) : (
12+
<Redirect to={{ pathname: "/login", state: { from: location } }} />
13+
)
14+
}
15+
/>
16+
);
17+
};

frontend/app/src/components/public/TargetCreate.tsx frontend/app/src/components/private/TargetCreate.tsx

+16-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useEffect, useState } from "react";
22
import { Form, Button, Row, Col, Container } from "react-bootstrap";
33
import { useHistory } from "react-router";
4+
import { authorized_fetch } from "../../utils/Auth";
45

56
export const TargetCreate = () => {
67
const [firstName, setFirstName] = useState<string>("");
@@ -10,26 +11,28 @@ export const TargetCreate = () => {
1011
const history = useHistory();
1112

1213
useEffect(() => {
14+
document.title = "Create Target";
1315
if (firstName.length > 0 && lastName.length > 0) {
1416
setDisabled(false);
1517
} else {
1618
setDisabled(true);
1719
}
1820
}, [firstName, lastName]);
1921

20-
const createTarget = (e: React.MouseEvent) => {
21-
fetch("/api/targets", {
22-
method: "POST",
23-
headers: { "content-type": "application/json" },
24-
body: JSON.stringify({
25-
first_name: firstName,
26-
last_name: lastName,
27-
dob: dob,
28-
}),
29-
})
30-
.then((res) => res.json())
31-
.then((data) => history.push("/targets/" + data.id))
32-
.catch(console.error);
22+
const createTarget = async (e: React.MouseEvent) => {
23+
const data = await authorized_fetch(
24+
"/api/targets",
25+
{ "content-type": "application/json" },
26+
{
27+
method: "POST",
28+
body: JSON.stringify({
29+
first_name: firstName,
30+
last_name: lastName,
31+
dob: dob,
32+
}),
33+
}
34+
);
35+
history.push("/targets/" + data.id);
3336
};
3437

3538
return (

frontend/app/src/components/public/TargetInfo.tsx frontend/app/src/components/private/TargetInfo.tsx

+16-12
Original file line numberDiff line numberDiff line change
@@ -11,42 +11,46 @@ import {
1111
} from "react-bootstrap";
1212

1313
import { Target } from ".";
14+
import { authorized_fetch } from "../../utils/Auth";
1415

1516
export const TargetInfo: FC = () => {
1617
const { id }: { id: string } = useParams();
1718
const [target, setTarget] = useState<Target | null>(null);
1819
const history = useHistory();
1920

2021
useEffect(() => {
21-
fetch("/api/targets/" + id, {
22-
method: "GET",
23-
})
24-
.then((res) => res.json())
22+
document.title = "Target Information";
23+
authorized_fetch(
24+
"/api/targets/" + id,
25+
{},
26+
{
27+
method: "GET",
28+
}
29+
)
2530
.then((data) => setTarget(data))
2631
.catch(console.error);
2732
}, [id]);
2833

29-
const deleteTarget = (e: React.MouseEvent) => {
34+
const deleteTarget = async (e: React.MouseEvent) => {
3035
if (window.confirm("Are you sure you want to delete this target ?")) {
31-
fetch("/api/targets/" + id, { method: "DELETE" })
32-
.then(() => history.push("/"))
33-
.catch(console.error);
36+
await authorized_fetch("/api/targets/" + id, {}, { method: "DELETE" });
37+
history.push("/");
3438
}
3539
};
3640

3741
const listPaths = target && (
3842
<ListGroup>
39-
{target.pictures.map((picture) => (
40-
<ListGroup.Item>{picture.path}</ListGroup.Item>
43+
{target.pictures.map((picture, idx) => (
44+
<ListGroup.Item key={idx}>{picture.path}</ListGroup.Item>
4145
))}
4246
</ListGroup>
4347
);
4448
const listAttributes = target && (
4549
<ListGroup>
4650
{Object.entries(target)
4751
.filter(([key, value]) => key !== "pictures")
48-
.map(([key, value]) => (
49-
<ListGroup.Item>
52+
.map(([key, value], idx) => (
53+
<ListGroup.Item key={idx}>
5054
<b>{key}</b> {value}
5155
</ListGroup.Item>
5256
))}

frontend/app/src/components/public/TargetSearch.tsx frontend/app/src/components/private/TargetSearch.tsx

+16-15
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,30 @@ import {
1414
import { Link } from "react-router-dom";
1515

1616
import { getName, Target } from ".";
17-
18-
const loadAllTargets = async () => {
19-
return fetch("/api/targets", {
20-
method: "GET",
21-
});
22-
};
17+
import { authorized_fetch } from "../../utils/Auth";
2318

2419
export const TargetSearch: FC = () => {
2520
const [targets, setTargets] = useState<Target[]>([]);
2621
const [targetsDefault, setTargetsDefault] = useState<Target[]>([]);
2722
const [searchQuery, setSearchQuery] = useState<string>("");
2823
const [loading, setLoading] = useState<boolean>(true);
2924

25+
const loadAllTargets = async () => {
26+
const data = await authorized_fetch(
27+
"/api/targets",
28+
{},
29+
{
30+
method: "GET",
31+
}
32+
);
33+
setTargetsDefault(data);
34+
setTargets(data);
35+
setLoading(false);
36+
};
37+
3038
useEffect(() => {
31-
document.title = "Home";
32-
loadAllTargets()
33-
.then((res) => res.json())
34-
.then((data) => {
35-
setTargetsDefault(data);
36-
setTargets(data);
37-
setLoading(false);
38-
})
39-
.catch(console.error);
39+
document.title = "Search Targets";
40+
loadAllTargets().catch(console.error);
4041
}, []);
4142

4243
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {

0 commit comments

Comments
 (0)