Skip to content

Commit 8775dd3

Browse files
Password reset flow
1 parent a4b2223 commit 8775dd3

File tree

12 files changed

+394
-157
lines changed

12 files changed

+394
-157
lines changed

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
# Secret key for JWT
12
SECRET_KEY=
3+
4+
# Database
25
DB_USER=
36
DB_PASSWORD=
47
DB_HOST=localhost
58
DB_PORT=5432
69
DB_NAME=
10+
11+
# Resend
12+
RESEND_API_KEY=

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t
1414

1515
Set your desired database name, username, and password in the .env file.
1616

17+
To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file.
18+
1719
## Start development database
1820

1921
`docker compose up -d`
@@ -32,10 +34,9 @@ Navigate to http://localhost:8000/
3234

3335
## To do
3436

35-
- Implement password recovery
36-
- Implement role/org system
37+
- Finish implementing role/org system
3738
- Implement user profile page
38-
- Add payments/billing system?
39+
- Add payments/billing system
3940

4041
## License
4142

main.py

Lines changed: 81 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
from fastapi.responses import RedirectResponse
66
from fastapi.staticfiles import StaticFiles
77
from fastapi.templating import Jinja2Templates
8-
from fastapi.exceptions import RequestValidationError, StarletteHTTPException
9-
from routers import auth, organization, role, user
10-
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens
11-
from utils.db import User
8+
from fastapi.exceptions import RequestValidationError, StarletteHTTPException, HTTPException
9+
from sqlmodel import Session
10+
from routers import authentication, organization, role, user
11+
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token
12+
from utils.db import User, get_session
1213

1314

1415
logger = logging.getLogger("uvicorn.error")
@@ -51,14 +52,15 @@ async def needs_new_tokens_handler(request: Request, exc: NeedsNewTokens):
5152
)
5253
return response
5354

54-
55-
@app.exception_handler(StarletteHTTPException)
56-
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
57-
return templates.TemplateResponse(
58-
"errors/error.html",
59-
{"request": request, "status_code": exc.status_code, "detail": exc.detail},
60-
status_code=exc.status_code,
61-
)
55+
# TODO: Make sure this only catches server errors and not 307 redirects
56+
# Create a custom server error class that inherits from StarletteHTTPException?
57+
# @app.exception_handler(StarletteHTTPException)
58+
# async def http_exception_handler(request: Request, exc: StarletteHTTPException):
59+
# return templates.TemplateResponse(
60+
# "errors/error.html",
61+
# {"request": request, "status_code": exc.status_code, "detail": exc.detail},
62+
# status_code=exc.status_code,
63+
# )
6264

6365

6466
@app.exception_handler(RequestValidationError)
@@ -72,151 +74,124 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
7274

7375
# -- Unauthenticated Routes --
7476

75-
76-
@app.get("/")
77-
async def read_home(
77+
# Define a dependency for common parameters
78+
async def common_unauthenticated_parameters(
7879
request: Request,
7980
user: Optional[User] = Depends(get_optional_user),
8081
error_message: Optional[str] = None,
82+
) -> dict:
83+
return {"request": request, "user": user, "error_message": error_message}
84+
85+
86+
@app.get("/")
87+
async def read_home(
88+
params: dict = Depends(common_unauthenticated_parameters)
8189
):
82-
if user:
90+
if params["user"]:
8391
return RedirectResponse(url="/dashboard", status_code=302)
84-
return templates.TemplateResponse(
85-
"index.html", {"request": request, "user": user,
86-
"error_message": error_message}
87-
)
92+
return templates.TemplateResponse("index.html", params)
8893

8994

9095
@app.get("/login")
9196
async def read_login(
92-
request: Request,
93-
user: Optional[User] = Depends(get_optional_user),
94-
error_message: Optional[str] = None,
97+
params: dict = Depends(common_unauthenticated_parameters)
9598
):
96-
if user:
99+
if params["user"]:
97100
return RedirectResponse(url="/dashboard", status_code=302)
98-
return templates.TemplateResponse(
99-
"authentication/login.html",
100-
{"request": request, "user": user, "error_message": error_message},
101-
)
101+
return templates.TemplateResponse("authentication/login.html", params)
102102

103103

104104
@app.get("/register")
105105
async def read_register(
106-
request: Request,
107-
user: Optional[User] = Depends(get_optional_user),
108-
error_message: Optional[str] = None,
106+
params: dict = Depends(common_unauthenticated_parameters)
109107
):
110-
if user:
108+
if params["user"]:
111109
return RedirectResponse(url="/dashboard", status_code=302)
112-
return templates.TemplateResponse(
113-
"authentication/register.html",
114-
{"request": request, "user": user, "error_message": error_message},
115-
)
110+
return templates.TemplateResponse("authentication/register.html", params)
116111

117112

118113
@app.get("/forgot_password")
119114
async def read_forgot_password(
120-
request: Request,
121-
user: Optional[User] = Depends(get_optional_user),
122-
error_message: Optional[str] = None,
115+
params: dict = Depends(common_unauthenticated_parameters),
116+
show_form: Optional[bool] = True,
123117
):
124-
if user:
118+
if params["user"]:
125119
return RedirectResponse(url="/dashboard", status_code=302)
126-
return templates.TemplateResponse(
127-
"authentication/forgot_password.html",
128-
{"request": request, "user": user, "error_message": error_message},
129-
)
120+
params["show_form"] = show_form
130121

131-
132-
@app.get("/reset_password")
133-
async def read_reset_password(
134-
request: Request,
135-
token: str,
136-
user: Optional[User] = Depends(get_optional_user),
137-
error_message: Optional[str] = None,
138-
):
139-
if user:
140-
return RedirectResponse(url="/dashboard", status_code=302)
141-
# TODO: Validate the token here?
142-
return templates.TemplateResponse(
143-
"authentication/reset_password.html",
144-
{
145-
"request": request,
146-
"token": token,
147-
"user": user,
148-
"error_message": error_message,
149-
},
150-
)
122+
return templates.TemplateResponse("authentication/forgot_password.html", params)
151123

152124

153125
@app.get("/about")
154-
async def read_about(
155-
request: Request,
156-
user: Optional[User] = Depends(get_optional_user),
157-
error_message: Optional[str] = None,
158-
):
159-
return templates.TemplateResponse(
160-
"about.html",
161-
{"request": request, "user": user, "error_message": error_message}
162-
)
126+
async def read_about(params: dict = Depends(common_unauthenticated_parameters)):
127+
return templates.TemplateResponse("about.html", params)
163128

164129

165130
@app.get("/privacy_policy")
166-
async def read_privacy_policy(
167-
request: Request,
168-
user: Optional[User] = Depends(get_optional_user),
169-
error_message: Optional[str] = None,
170-
):
171-
return templates.TemplateResponse(
172-
"privacy_policy.html",
173-
{"request": request, "user": user, "error_message": error_message},
174-
)
131+
async def read_privacy_policy(params: dict = Depends(common_unauthenticated_parameters)):
132+
return templates.TemplateResponse("privacy_policy.html", params)
175133

176134

177135
@app.get("/terms_of_service")
178-
async def read_terms_of_service(
179-
request: Request,
180-
user: Optional[User] = Depends(get_optional_user),
181-
error_message: Optional[str] = None,
136+
async def read_terms_of_service(params: dict = Depends(common_unauthenticated_parameters)):
137+
return templates.TemplateResponse("terms_of_service.html", params)
138+
139+
140+
@app.get("/reset_password")
141+
async def read_reset_password(
142+
email: str,
143+
token: str,
144+
params: dict = Depends(common_unauthenticated_parameters),
145+
session: Session = Depends(get_session)
182146
):
183-
return templates.TemplateResponse(
184-
"terms_of_service.html",
185-
{"request": request, "user": user, "error_message": error_message},
186-
)
147+
authorized_user, _ = get_user_from_reset_token(email, token, session)
148+
149+
# Raise informative error to let user know the token is invalid and may have expired
150+
if not authorized_user:
151+
raise HTTPException(status_code=400, detail="Invalid or expired token")
152+
153+
params["email"] = email
154+
params["token"] = token
155+
156+
return templates.TemplateResponse("authentication/reset_password.html", params)
187157

188158

189159
# -- Authenticated Routes --
190160

191161

192-
@app.get("/dashboard")
193-
async def read_dashboard(
162+
# Define a dependency for common parameters
163+
async def common_authenticated_parameters(
194164
request: Request,
195165
user: User = Depends(get_authenticated_user),
196166
error_message: Optional[str] = None,
167+
) -> dict:
168+
return {"request": request, "user": user, "error_message": error_message}
169+
170+
171+
# Redirect to home if user is not authenticated
172+
@app.get("/dashboard")
173+
async def read_dashboard(
174+
params: dict = Depends(common_authenticated_parameters)
197175
):
198-
return templates.TemplateResponse(
199-
"dashboard/index.html",
200-
{"request": request, "user": user, "error_message": error_message},
201-
)
176+
if not params["user"]:
177+
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
178+
return templates.TemplateResponse("dashboard/index.html", params)
202179

203180

204-
@app.get("/user_profile")
205-
async def read_user_profile(
206-
request: Request,
207-
user: User = Depends(get_authenticated_user),
208-
error_message: Optional[str] = None,
181+
@app.get("/profile")
182+
async def read_profile(
183+
params: dict = Depends(common_authenticated_parameters)
209184
):
210-
return templates.TemplateResponse(
211-
"users/profile.html",
212-
{"request": request, "user": user, "error_message": error_message},
213-
)
185+
if not params["user"]:
186+
# Changed to 302
187+
return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
188+
return templates.TemplateResponse("users/profile.html", params)
214189

215190

216191
# -- Include Routers --
217192

218193

219-
app.include_router(auth.router)
194+
app.include_router(authentication.router)
220195
app.include_router(organization.router)
221196
app.include_router(role.router)
222197
app.include_router(user.router)

0 commit comments

Comments
 (0)