Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .env.template
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ALLOW_ORIGINS=*
DATABASE_URL=postgres+asyncpg://user:password@host/db_name
DATABASE_URL=postgres+asyncpg://postgres:password@localhost/futuramaapi
TRUSTED_HOST=localhost
SECRET_KEY=PRODUCTION-SECRET-KEY
# Optional
Expand All @@ -16,7 +16,7 @@ EMAIL_HOST=email-host
EMAIL_API_KEY=secret-api-key

# Broker
REDISCLOUD_URL=redis://user:password@host:6379
REDISCLOUD_URL=redis://localhost:6379

# Logging
# Optional, if not set alerts will not be fired.
Expand All @@ -41,5 +41,5 @@ ENABLE_HTTPS_REDIRECT=false
ENABLE_SENTRY=false
SEND_EMAILS=true
COUNT_API_REQUESTS=true
USER_SIGNUP=ture
USER_SIGNUP=true
USER_DELETION=false
1 change: 1 addition & 0 deletions futuramaapi/routers/services/auth/get_user_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
class UserAuthMessageType(StrEnum):
password_changed = "password_changed" # noqa: S105
incorrect_login = "incorrect_login"
signup_success = "signup_success"


class GetUserAuthService(BaseTemplateService):
Expand Down
22 changes: 22 additions & 0 deletions futuramaapi/routers/services/auth/get_user_signup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from enum import StrEnum
from typing import Any

from futuramaapi.routers.services import BaseTemplateService


class UserSignupMessageType(StrEnum):
signup_disabled = "signup_disabled"
user_exists = "user_exists"
signup_success = "signup_success"
validation_error = "validation_error"


class GetUserSignupService(BaseTemplateService):
template_name = "signup.html"

message_type: UserSignupMessageType | None

async def get_context(self, *args, **kwargs) -> dict[str, Any]:
return {
"message_type": self.message_type,
}
75 changes: 75 additions & 0 deletions futuramaapi/routers/services/auth/signup_cookie_session_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from asyncpg import UniqueViolationError
from fastapi import status
from fastapi.responses import RedirectResponse
from pydantic import EmailStr, Field, SecretStr, field_validator
from sqlalchemy import exc

from futuramaapi.core import feature_flags
from futuramaapi.db.models import UserModel
from futuramaapi.routers.services import BaseSessionService
from futuramaapi.routers.services.auth.get_user_signup import UserSignupMessageType


class SignupCookieSessionUserService(BaseSessionService[RedirectResponse]):
name: str = Field(
min_length=1,
max_length=64,
pattern=r"^[A-Za-z\s\-']+$",
)
surname: str = Field(
min_length=1,
max_length=64,
pattern=r"^[A-Za-z\s\-']+$",
)
email: EmailStr = Field(max_length=320)
username: str = Field(
min_length=5,
max_length=64,
pattern=r"^[A-Za-z][A-Za-z0-9_]{4,}$",
)
password: SecretStr = Field(
min_length=8,
max_length=128,
)

@field_validator("password", mode="after")
@classmethod
def validate_password(cls, value: SecretStr, /) -> SecretStr:
password: str = value.get_secret_value()
has_letter = any(character.isalpha() for character in password)
has_digit = any(character.isdigit() for character in password)
if not has_letter or not has_digit:
raise ValueError("Password must contain at least one letter and one digit")

return value

async def process(self, *args, **kwargs) -> RedirectResponse:
if not feature_flags.user_signup:
return RedirectResponse(
url=f"/signup?messageType={UserSignupMessageType.signup_disabled}",
status_code=status.HTTP_302_FOUND,
)

user: UserModel = UserModel(
name=self.name,
surname=self.surname,
email=self.email,
username=self.username,
password=self.hasher.encode(self.password.get_secret_value()),
)
self.session.add(user)

try:
await self.session.commit()
except exc.IntegrityError as err:
if hasattr(err.orig, "sqlstate") and err.orig.sqlstate == UniqueViolationError.sqlstate:
return RedirectResponse(
url=f"/signup?messageType={UserSignupMessageType.user_exists}",
status_code=status.HTTP_302_FOUND,
)
raise

return RedirectResponse(
url=f"/auth?messageType={UserSignupMessageType.signup_success}",
status_code=status.HTTP_302_FOUND,
)
65 changes: 64 additions & 1 deletion futuramaapi/routers/views/api.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from typing import Annotated

from fastapi import APIRouter, Depends, Query, Request, Response, status
from fastapi import APIRouter, Depends, Form, Query, Request, Response, status
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import ValidationError

from futuramaapi.routers.services.about.get_about import GetAboutService
from futuramaapi.routers.services.auth.auth_cookie_session_user import AuthCookieSessionUserService
from futuramaapi.routers.services.auth.get_user_auth import GetUserAuthService, UserAuthMessageType
from futuramaapi.routers.services.auth.get_user_signup import GetUserSignupService, UserSignupMessageType
from futuramaapi.routers.services.auth.logout_cookie_session_user import LogoutCookieSessionUserService
from futuramaapi.routers.services.auth.signup_cookie_session_user import SignupCookieSessionUserService
from futuramaapi.routers.services.changelog.get_changelog import GetChangelogService
from futuramaapi.routers.services.index.get_index import GetIndexService
from futuramaapi.routers.services.sitemaps.get_sitemap import GetSiteMapService
Expand Down Expand Up @@ -157,6 +160,66 @@ async def logout_cookie_session_user(
return await service()


@router.get(
"/signup",
include_in_schema=False,
name="user_signup",
)
async def user_signup(
request: Request,
message_type: Annotated[
UserSignupMessageType | None,
Query(
alias="messageType",
),
] = None,
) -> Response:
service: GetUserSignupService = GetUserSignupService(
message_type=message_type,
context={
"request": request,
},
)
return await service()


@router.post(
"/signup",
include_in_schema=False,
name="signup_cookie_session_user",
)
async def signup_cookie_session_user( # noqa: PLR0913
request: Request,
name: Annotated[str, Form()] = "",
surname: Annotated[str, Form()] = "",
email: Annotated[str, Form()] = "",
username: Annotated[str, Form()] = "",
password: Annotated[str, Form()] = "",
) -> RedirectResponse:
if not all([name.strip(), surname.strip(), email.strip(), username.strip(), password]):
return RedirectResponse(
url=f"/signup?messageType={UserSignupMessageType.validation_error}",
status_code=status.HTTP_302_FOUND,
)
try:
service: SignupCookieSessionUserService = SignupCookieSessionUserService(
name=name,
surname=surname,
email=email,
username=username,
password=password,
context={
"request": request,
},
)
except ValidationError:
return RedirectResponse(
url=f"/signup?messageType={UserSignupMessageType.validation_error}",
status_code=status.HTTP_302_FOUND,
)
return await service()


@router.post(
"/auth",
include_in_schema=False,
Expand Down
17 changes: 8 additions & 9 deletions static/css/auth.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,7 @@
border: 1px solid #e5e7eb;
font-size: 0.9rem;
color: #111827;
transition:
border-color 150ms ease,
box-shadow 150ms ease;
transition: border-color 150ms ease, box-shadow 150ms ease;
}

.auth-field input:focus {
Expand All @@ -105,9 +103,7 @@
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 150ms ease,
box-shadow 150ms ease,
transition: background-color 150ms ease, box-shadow 150ms ease,
transform 120ms ease;
}

Expand All @@ -122,7 +118,10 @@

.auth-button:focus-visible {
outline: none;
box-shadow:
0 0 0 2px #ffffff,
0 0 0 4px rgba(37, 99, 235, 0.4);
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px rgba(37, 99, 235, 0.4);
}

.auth-footer-link {
margin-top: 1.5rem;
margin-bottom: 0;
}
14 changes: 12 additions & 2 deletions templates/auth.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@
class="auth-message error"
>
Hmm… that username or password doesn’t match our records.
</p>
{% endif %}
</p> {% elif message_type == 'signup_success' %}
<p
class="auth-message success"
>
Account created! You can now sign in.
</p> {% endif %}
<p
class="auth-subtitle"
>
Expand Down Expand Up @@ -88,6 +92,12 @@
Sign in
</button>
</form>
<p
class="auth-subtitle auth-footer-link"
>
Don't have an account?
<a href="{{ relative_path_for('user_signup') }}">Sign up</a>
</p>

</div>
</div>
Expand Down
10 changes: 10 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@
</form>
{% endif %}
</li>
{% if not current_user %}
<li
class="nav-item"
>
<a
class="nav-link{% if active_page == 'user_signup' %} active{% endif %}"
href="{{ relative_path_for('user_signup') }}"
>Sign Up</a>
</li>
{% endif %}
</ul>
</div>
</div>
Expand Down
Loading
Loading