06 Jul 2024
Continúo refinando la librería auth-lib, pensada para proyectos que necesitan gestionar sign up, login, etc, siguiendo los lineamientos de Clean Architecture. En esta entrega voy a añadir el campo rol, ya que es común tener que diferenciar entre el inicio de sesión de un admin y el de un usuario común, con menos privilegios.
En casi todos los proyectos suelen haber más de un tipo de usuario. El usuario común o final, un admin, el owner de un recurso, etc. Por tanto, he pensado que nuestra librería debería contemplar esta la posibilidad en nuestro casos de uso. En el SignUp, por ejemplo, podríamos querer indicar qué roles permitiremos (Por ejemplo, en muchos proyectos un usuario no podrá autoregistrarse como admin), o en el mismo caso de uso login, además de validar usuario y contraseña, también valide si quien intenta iniciar sesión tiene un rol admitido en el contexto donde se use el caso de uso Login.
Como vengo haciendo en los posts anteriores, este es el link desde en el cual me basaré para introducir los cambios a lo largo del post.
¿Cómo afecta esto de los roles de usuario a nuestro SignUp? Empezaremos con un enfoque práctico, esto es que todos los usuarios se generen con un rol básico por defecto. Luego cada proyecto podrá definir cómo modificar el rol de un usuario y quizás esto ya escape al scope de la librería.
Vamos a crear una clase con los roles permitidos, que a su vez pueda ser extensible en caso de que otro proyecto quiera contemplar otra lista de roles, distinta a la predeterminada.
En PHP, para definir un listado acotado de valores posibles, contamos con los Enums. En particular, para este caso nos pueden interesar los Backed Nums, que son enumerations en las que podemos definir escalares para cada valor del enum. Debemos tener en cuenta que los enums no pueden extenderse, por tanto, al usar simplemente enums, impediremos que usuarios de la librería puedan definir su propia lista de roles. Para poder tener enums reemplazables haremos que los enums implementen una interfaz:
interface Role {
public function getValue(): string;
}
enum BasicRoles: string implements Role {
case Admin = 'admin';
case User = 'user';
public function getValue(): string {
return $this->value;
}
}De esta manera, si alguien quisiera definir su propio conjunto de roles, solo debe implementar la interfaz Role. Por ejemplo:
enum ExtendedRoles: string implements Role {
case Admin = 'admin';
case User = 'user';
case Manager = 'manager';
case Guest = 'guest';
public function getValue(): string {
return $this->value;
}
}Ahora veremos cómo podemos usar estas clases para crear usuarios durante el SignUp. Lo primero es dotar a nuestro User de un rol:
class User
{
private Role $role;
...
public function getRole(): Role
{
return $this->role;
}
public function setRole(Role $role): self
{
$this->role = $role;
return $this;
}
}¿En dónde deberíamos configurar este rol? Pues en la clase encargada de crear usuarios: nuestra factory del SignUp:
class SignUpUserFactory
{
public function createUserFromRequest(
SignUpRequest $request
): User {
return (new User($request->username))
->hashPassword($request->password)
->setRole(BasicRoles::User);
}
}Nuestra factory ya puede instanciar usuarios con un role predeterminado. ¿Cómo podemos permitir al consumidor de nuestra librería el que pueda usar un juego de roles diferentes en lugar de obligarlo a usar BasicRoles? Vamos a darle a nuestra factory la habilidad de indicarle el rol que queremos por default:
class SignUpUserFactory
{
public function __construct(
private Role $defaultRole = BasicRoles::User
) {
}
public function createUserFromRequest(
SignUpRequest $request
): User {
return (new User($request->username))
->hashPassword($request->password)
->setRole($this->getRole());
}
private function getRole(): Role
{
return $this->defaultRole;
}
}De esta forma, alguien podría instanciar la factory indicando el rol por default que le gustaría usar para sus usuarios.
¿Qué tal si el sistema donde se usará la librería admite roles que el usuario puede elegir a la hora de registrar? Por ejemplo: Profesor o Estudiante. Vamos a ver cómo mejoramos nuestro SignUp para permitir estos escenarios.
Supongamos que definimos este juego de roles:
enum SchoolRoles: string implements Role {
case Admin = 'admin';
case Student = 'student';
case Teacher = 'teacher';
public function getValue(): string {
return $this->value;
}
}Queremos que nuestra factory, en función de algún valor que venga de afuera, pueda crear el usuario con el rol adecuado. Para ello, debemos modificar el request del SignUp para permitir especificar un rol:
class SignUpRequest {
private string $role = '';
public function __construct(
public readonly string $username,
public readonly string $password
) {
}
public function withRole(string $role): self
{
$this->role = $role;
return $this;
}
public function getRole(): string
{
return $this->role;
}
}En la factory debemos validar que el rol que viene en el request sea un valor permitido por nuestro conjunto de valores del enum:
class SignUpUserFactory
{
public function __construct(
private Role $defaultRole = BasicRoles::User
) {
}
public function createUserFromRequest(
SignUpRequest $request
): User {
return (new User($request->username))
->hashPassword($request->password)
->setRole($this->getRole($request));
}
private function getRole(SignUpRequest $request): Role
{
if ($request->getRole() !== '') {
return $this->defaultRole::from($request->getRole());
}
return $this->defaultRole;
}
}Nuestra factory ahora revisa si el request trae un rol definido. Si ese es el caso, intenta crear un enum a partir del valor recibido en el request, usando el método from de BackedEnum. Este método automáticamente comprueba la validez del valor a partir del cual vamos a crear el enum. Por otra parte, si el request no trae un valor definido, entonces la factory retorna el enum por default.
SignUpNos falta un control más por implementar: ¿Qué sucede si alguien altera un request y pasa un rol con privilegios, por ejemplo indicando role=admin, donde se espera que los usuarios registrados sean usuarios comunes? Ahora mismo, nuestra factory simplemente accedería a crear al usuario con ese rol. Así que la siguiente iteración será para dotar a nuestra factory de la capacidad de limitar los roles permitidos durante el SignUp.
Vamos a definir una clase para modelar la lista de roles permitidos:
class RoleList
{
private array $roles = [];
public function addRole(Role $role): self
{
$this->roles[] = $role;
return $this;
}
public function contains(Role $role): bool
{
foreach ($this->roles as $r) {
if ($r->getValue() == $role->getValue()) {
return true;
}
}
return false;
}
}Ahora modificamos nuestra factory para que use esta lista de roles para verificar que el rol que viene en el request esté entre los roles que queremos permitir:
class SignUpUserFactory
{
public function __construct(
private Role $defaultRole = BasicRoles::User,
private ?RoleList $admittedRoles = null
) {
$this->admittedRoles = $admittedRoles
?? (new RoleList())->addRole(BasicRoles::User);
}
public function createUserFromRequest(
SignUpRequest $request
): User {
return (new User($request->username))
->hashPassword($request->password)
->setRole($this->getRole($request));
}
private function getRole(SignUpRequest $request): Role
{
if ($request->getRole() !== '') {
$role = $this->defaultRole::from($request->getRole());
if ($this->admittedRoles->contains($role)) {
return $role;
} else {
throw new RoleNotAdmittedException($role->getValue());
}
}
return $this->defaultRole;
}
}Observar el método getRole. Aquí es donde verificamos que el rol presente en el request esté presente también en nuestra lista admittedRoles. Si el rol informado en el request no está en la lista de roles permitidos, arrojamos una excepción.
Debemos instruir a nuestro interactor para que, en caso de que la excepción RoleNotAdmittedException sea lanzada, rechace el signup:
class SignUpInteractor implements SignUpInputPort
{
...
public function signUp(SignUpRequest $request): void
{
...
try {
$user = $this->userFactory->createUserFromRequest($request);
} catch (RoleNotAdmittedException $e) {
$this->output->roleNotAdmitted($e->role);
return;
}
...
}
}Hasta aquí, hemos añadido a nuestro SignUp la posibilidad de especificar roles de usuarios. También hemos desarrollado una manera de limitar qué roles se pueden escoger durante el SignUp. Ahora toca incorporar el concepto de roles a nuestro Login para poder indicarle qué roles queremos permitir en ciertos escenarios. Habrá momentos donde solo queremos permitir el login de un usuario regular, o si estamos usando el Login desde un panel de administración, solo querremos permitir login de usuarios con privilegios de administrador.
Para que el Login sea capaz de controlar el tipo de rol permitido en un cierto escenario, vamos a informarle ese listado en el constructor:
class LoginInteractor implements LoginInputPort
{
public function __construct(
private UserRepository $userRepository,
private LoginOutputPort $output,
private ?RoleList $admittedRoles = null
) {
$this->admittedRoles = $admittedRoles
?? (new RoleList())->addRole(BasicRoles::User);
}
...
}Por defecto, vamos a permitir el rol básico User. Si queremos permitir más roles, podemos añadirlos a una RoleList y pasarla en el constructor del LoginInteractor. Luego, en nuestro método login, validamos que el rol que trae nuestro usuario es el que esperamos:
class LoginInteractor implements LoginInputPort
{
...
public function login(LoginRequest $request): void
{
$user = $this->userRepository->findByUsername($request->username);
if (is_null($user)) {
$this->output->userNotFound();
return;
}
if (!$this->admittedRoles->contains($user->getRole())) {
$this->output->roleNotAdmitted($user->getRole()->value);
return;
}
if (!$user->passwordMatches($request->password)) {
$this->output->passwordNotMatch();
return;
}
$this->output->userLoggedIn(new LoginResponse($user->getId()));
}
..
}Por último, para cubrir aquellos casos donde alguien quiera usar la librería sin roles, podemos añadir a los usuarios el rol por defecto User:
class User
{
private UserId $id;
private Role $role = BasicRoles::User;
...
}En este post hemos añadido un mecanismo para usar roles en nuestra librería, tanto durante el SignUp como el Login. ¿Con qué podemos seguir? Todavía hay mucho trabajo por hacer: implementar algunos repositorios básicos, añadir caso de uso para recuperar contraseña, modificar datos de usuario, etc.
¡Tu mensaje fue recibido! Una vez que sea aprobado, estará visible para los demás visitantes.
Cerrar