Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add routes for user activation #403

Merged
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Expand Up @@ -70,6 +70,12 @@ Alternatively, you can run `pytest` yourself. The MongoDB unit tests will be ski
pytest
```

There are quite a few unit tests, so you might run into ulimit issues where there are too many open file descriptors. You may be able to set a new, higher limit temporarily with:

```bash
ulimit -n 2048
```

### Format the code

Execute the following command to apply `isort` and `black` formatting:
Expand Down
6 changes: 3 additions & 3 deletions docs/configuration/model.md
Expand Up @@ -4,7 +4,7 @@

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should revert those changes. (except grammar fixes of courses)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, agreed

* `id` (`UUID4`) – Unique identifier of the user. Default to a **UUID4**.
* `email` (`str`) – Email of the user. Validated by [`email-validator`](https://github.com/JoshData/python-email-validator).
* `is_active` (`bool`) – Whether or not the user is active. If not, login and forgot password requests will be denied. Default to `True`.
* `is_active` (`bool`) – Whether or not the user is activated. If not, login and forgot password requests will be denied. Default to `True` if `activation_callback` is not supplied. Default to `False` if `activation_callback` is supplied.
* `is_superuser` (`bool`) – Whether or not the user is a superuser. Useful to implement administration logic. Default to `False`.

## Define your models
Expand Down Expand Up @@ -38,11 +38,11 @@ class UserDB(User, models.BaseUserDB):
pass
```

You can of course add you own properties there to fit to your needs!
You can of course add your own properties there to fit to your needs!

## Next steps

Depending on your database backend, database configuration will differ a bit.
Depending on your database backend, the database configuration will differ a bit.

[I'm using SQLAlchemy](databases/sqlalchemy.md)

Expand Down
85 changes: 82 additions & 3 deletions docs/configuration/routers/register.md
@@ -1,9 +1,26 @@
# Register routes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should revert those changes. (except grammar fixes of courses)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, agreed


The register router will generate a `/register` route to allow a user to create a new account.
The register router will generate a `/register` route to allow a user to create a new account, and optionally an `/activate` route to allow activation of the user account.

Check the [routes usage](../../usage/routes.md) to learn how to use them.

## User activation

Each user account has an associated `is_active` attribute. This determines whether or not the user is activated. Only activated users can log in and request a password reset. Check the [user model](../../configuration/model.md) to learn more about the attributes of a user.

By default, any users created by calling the `/register` route are activated upon initialisation.

If user verification is required, then the `activation_callback` must be supplied to `get_register_router`, the register router generator. When this callback is supplied, newly registered users are not activated by default, and a corresponding `/activate` route is created.

User activation then proceeds as follows:

* The user registers using the `/register` route.
* The `activation_callback` is called, with a unique activation token in the request body.
* The user is activated if this token is supplied as a parameter to the `/activate` route before token expiry (after `activation_token_lifetime_seconds`).
* Finally, the `after_register` callback is called once the user is activated.

Email verification can be implemented within `activation_callback`, emailing the user with the corresponding `/activate` URL. An example can be found [below](#activation-callback).

## Setup

```py
Expand All @@ -30,11 +47,34 @@ app.include_router(
prefix="/auth",
tags=["auth"],
)

```
`get_register_router`

Parameters:

* `after_register: Optional[Callable[[models.UD, Request], None]] = None`

Optional callback which takes a **user** (`models.UD`) and a **request** (`Request`) and is called upon user activation.

* `activation_callback: Optional[Callable[[models.UD, str, Request], None]] = None`

Optional callback which takes a **user** (`models.UD`), a **token** (`str`) and a **request** (`Request`). If supplied, the user is not activated by default, and can be activated by passing the token to the activate route. If not supplied, no activation step occurs and the user is activated by default.

* `activation_token_secret: str = None`:

Cryptographic secret to encode activation token. Required if `activation_callback` supplied.

* `activation_token_lifetime_seconds: int = 3600`:

Lifetime of the activation token in seconds, if activation_callback supplied. **Defaults to 3600**.

## After register callback

## After register
You can provide a custom function, `after_register` to be called after a user is successfully activated. This can occur either upon registration if no `activation_callback` is specified, or instead upon activation otherwise. This is called with **two arguments**:

You can provide a custom function to be called after a successful registration. It is called with **two argument**: the **user** that has just registered, and the original **`Request` object**.
* The `user` that has just registered
* The original `Request` object

Typically, you'll want to **send a welcome e-mail** or add it to your marketing analytics pipeline.

Expand All @@ -52,3 +92,42 @@ app.include_router(
tags=["auth"],
)
```

## Activation callback

You can optionally provide an `activation_callback` for [custom user activation](#user-activation). It is called with **three arguments**:

* The `user` that has just registered
* The activation `token` associated with that user
* The original `Request` object

You must also supply `activation_token_secret` for this case - a cryptographic secret used to sign the token.

`get_register_router` automatically initializes the `/activate` route when `activation_token_secret` and `activation_callback` are supplied.

A token will be passed to your `activation_callback`. This token can be used to create a url to the `/activate` router, which will in turn activate a user.

Typically, you'll want to **send an activation e-mail** which contains this URL.

Example:

```py
def activation_callback(
created_user: Type[models.BaseUserCreate],
token: str,
request: Request
):
confirm_url = request.url_for("activate", token=token)
print('User {user.id} is to be activated. Visit {confirm_url} to activate.')

def on_after_register(user: UserDB, request: Request):
print(f"User {user.id} has registered.")

app.include_router(
fastapi_users.get_register_router(
after_register = on_after_register,
activation_callback = activation_callback,
activation_token_secret = 'teststring'
), prefix="/auth", tags=["auth"]
)
```
59 changes: 58 additions & 1 deletion docs/usage/routes.md
Expand Up @@ -44,7 +44,7 @@ Logout the authenticated user against the method named `name`. Check the corresp

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should revert those changes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, agreed

### `POST /register`

Register a new user. Will call the `after_register` [handler](../configuration/routers/register.md#after-register) on successful registration.
Register a new user. Will call the `after_register` [handler](../configuration/routers/register.md#after-register) on successful activation.

!!! abstract "Payload"
```json
Expand All @@ -54,6 +54,8 @@ Register a new user. Will call the `after_register` [handler](../configuration/r
}
```

If `activation_callback` not supplied:

!!! success "`201 Created`"
```json
{
Expand All @@ -64,6 +66,18 @@ Register a new user. Will call the `after_register` [handler](../configuration/r
}
```

If `activation_callback` supplied:

!!! success "`201 Created`"
```json
{
"id": "57cbb51a-ab71-4009-8802-3f54b4f2e23",
"email": "king.arthur@camelot.bt",
"is_active": false,
"is_superuser": false
}
```

!!! fail "`422 Validation Error`"

!!! fail "`400 Bad Request`"
Expand All @@ -74,6 +88,49 @@ Register a new user. Will call the `after_register` [handler](../configuration/r
"detail": "REGISTER_USER_ALREADY_EXISTS"
}
```
## Activate router

### `POST /activate`

Activate a new user. Will call the `after_register` [handler](../configuration/routers/register.md#activation-callback) on successful activation.

!!! abstract "Payload"
```json
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiNmJhYTI4OWYtOGE0OC00ZGMwLThiM2UtMmViOTgwOGIxODQ2IiwiYXVkIjoiZmFzdGFwaS11c2VyczphY3RpdmF0ZSIsImV4cCI6MTYwNjMyOTA5OH0.-HWG4j5_ygtRahiiBcwLfMHOFq_ydcJegHyK65ppchs"
}
```

!!! success "`202 Accepted`" User has been activated

!!! fail "`400 Bad Request`"
The link has already been used.

```json
{
"detail": "ACTIVATE_USER_LINK_USED"
}
```

!!! fail "`400 Bad Request`"
The token expired.

```json
{
"detail": "ACTIVATE_USER_TOKEN_EXPIRED"
}
```

!!! fail "`400 Bad Request`"
Bad token.

```json
{
"detail": "ACTIVATE_USER_BAD_TOKEN"
}
```

!!! fail "`422 Validation Error`"

## Reset password router

Expand Down
36 changes: 36 additions & 0 deletions fastapi_users/authentication/__init__.py
Expand Up @@ -75,13 +75,27 @@ async def get_optional_current_active_user(*args, **kwargs):
return None
return user

@with_signature(signature, func_name="get_optional_current_verified_user")
async def get_optional_current_verified_user(*args, **kwargs):
user = await get_optional_current_active_user(*args, **kwargs)
if not user or not user.is_verified:
return None
return user

@with_signature(signature, func_name="get_optional_current_superuser")
async def get_optional_current_superuser(*args, **kwargs):
user = await get_optional_current_active_user(*args, **kwargs)
if not user or not user.is_superuser:
return None
return user

@with_signature(signature, func_name="get_optional_current_verified_superuser")
async def get_optional_current_verified_superuser(*args, **kwargs):
user = await get_optional_current_verified_user(*args, **kwargs)
if not user or not user.is_superuser:
return None
return user

@with_signature(signature, func_name="get_current_user")
async def get_current_user(*args, **kwargs):
user = await get_optional_current_user(*args, **kwargs)
Expand All @@ -96,6 +110,13 @@ async def get_current_active_user(*args, **kwargs):
raise self._get_credentials_exception()
return user

@with_signature(signature, func_name="get_current_verified_user")
async def get_current_verified_user(*args, **kwargs):
user = await get_optional_current_verified_user(*args, **kwargs)
if user is None:
raise self._get_credentials_exception()
return user

@with_signature(signature, func_name="get_current_superuser")
async def get_current_superuser(*args, **kwargs):
user = await get_optional_current_active_user(*args, **kwargs)
Expand All @@ -105,12 +126,27 @@ async def get_current_superuser(*args, **kwargs):
raise self._get_credentials_exception(status.HTTP_403_FORBIDDEN)
return user

@with_signature(signature, func_name="get_current_verified_superuser")
async def get_current_verified_superuser(*args, **kwargs):
user = await get_optional_current_verified_user(*args, **kwargs)
if user is None:
raise self._get_credentials_exception()
if not user.is_superuser:
raise self._get_credentials_exception(status.HTTP_403_FORBIDDEN)
return user

self.get_current_user = get_current_user
self.get_current_active_user = get_current_active_user
self.get_current_verified_user = get_current_verified_user
self.get_current_superuser = get_current_superuser
self.get_current_verified_superuser = get_current_verified_superuser
self.get_optional_current_user = get_optional_current_user
self.get_optional_current_active_user = get_optional_current_active_user
self.get_optional_current_verified_user = get_optional_current_verified_user
self.get_optional_current_superuser = get_optional_current_superuser
self.get_optional_current_verified_superuser = (
get_optional_current_verified_superuser
)

async def _authenticate(self, *args, **kwargs) -> Optional[BaseUserDB]:
for backend in self.backends:
Expand Down
1 change: 1 addition & 0 deletions fastapi_users/db/sqlalchemy.py
Expand Up @@ -57,6 +57,7 @@ class SQLAlchemyBaseUserTable:
hashed_password = Column(String(length=72), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)


class SQLAlchemyBaseOAuthAccountTable:
Expand Down
1 change: 1 addition & 0 deletions fastapi_users/db/tortoise.py
Expand Up @@ -14,6 +14,7 @@ class TortoiseBaseUserModel(models.Model):
hashed_password = fields.CharField(null=False, max_length=255)
is_active = fields.BooleanField(default=True, null=False)
is_superuser = fields.BooleanField(default=False, null=False)
is_verified = fields.BooleanField(default=False, null=False)

async def to_dict(self):
d = {}
Expand Down