Add user authorization

This commit is contained in:
gabdevele
2026-06-02 20:01:56 +02:00 Unverified
parent 795383a98c
commit 54aa4cb73e
13 changed files with 361 additions and 36 deletions
+74 -15
View File
@@ -4,8 +4,26 @@
#include <drogon/drogon.h>
#include <algorithm>
namespace visione::auth
{
namespace
{
std::string primaryRole(const std::vector<std::string> &roles)
{
if (std::ranges::find(roles, "admin") != roles.end())
{
return "admin";
}
if (std::ranges::find(roles, "operator") != roles.end())
{
return "operator";
}
return "citizen";
}
} // namespace
std::optional<std::string> bearerToken(const drogon::HttpRequestPtr &request)
{
const auto header = request->getHeader("Authorization");
@@ -26,41 +44,82 @@ std::optional<UserContext> authenticate(const drogon::HttpRequestPtr &request)
}
auto db = drogon::app().getDbClient();
const auto result = db->execSqlSync(
"SELECT u.id, u.email, COALESCE(MIN(r.name), 'citizen') AS role "
const auto userResult = db->execSqlSync(
"SELECT u.id, u.email, u.must_change_password "
"FROM api_tokens t "
"JOIN users u ON u.id = t.user_id "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"WHERE t.token_hash = ? AND t.revoked_at IS NULL "
"AND t.expires_at > NOW() AND u.banned = FALSE "
"GROUP BY u.id, u.email",
"AND t.expires_at > NOW() AND u.banned = FALSE",
crypto::hashSecret(*token));
if (result.empty())
if (userResult.empty())
{
return std::nullopt;
}
const auto &row = result[0];
return UserContext{row["id"].as<long long>(),
row["email"].as<std::string>(),
row["role"].as<std::string>()};
const auto userId = userResult[0]["id"].as<long long>();
const auto roleRows = db->execSqlSync(
"SELECT r.name FROM user_roles ur "
"JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ? ORDER BY r.name",
userId);
const auto permissionRows = db->execSqlSync(
"SELECT DISTINCT p.name FROM user_roles ur "
"JOIN role_permissions rp ON rp.role_id = ur.role_id "
"JOIN permissions p ON p.id = rp.permission_id "
"WHERE ur.user_id = ? ORDER BY p.name",
userId);
std::vector<std::string> roles;
roles.reserve(roleRows.size());
for (const auto &row : roleRows)
{
roles.push_back(row["name"].as<std::string>());
}
if (roles.empty())
{
roles.push_back("citizen");
}
std::vector<std::string> permissions;
permissions.reserve(permissionRows.size());
for (const auto &row : permissionRows)
{
permissions.push_back(row["name"].as<std::string>());
}
UserContext context;
context.id = userId;
context.email = userResult[0]["email"].as<std::string>();
context.roles = std::move(roles);
context.permissions = std::move(permissions);
context.role = primaryRole(context.roles);
context.mustChangePassword = userResult[0]["must_change_password"].as<bool>();
return context;
}
bool hasRole(const UserContext &user, const std::string &role)
{
return user.role == role;
return std::ranges::find(user.roles, role) != user.roles.end();
}
bool hasPermission(const UserContext &user, const std::string &permission)
{
return std::ranges::find(user.permissions, permission) !=
user.permissions.end();
}
bool canManageReports(const UserContext &user)
{
return user.role == "admin" || user.role == "operator";
return isAdmin(user) || hasPermission(user, "reports.manage");
}
bool isAdmin(const UserContext &user)
{
return user.role == "admin";
return hasRole(user, "admin") || hasPermission(user, "admin.manage");
}
bool mustChangePassword(const UserContext &user)
{
return user.mustChangePassword;
}
} // namespace visione::auth
+6 -1
View File
@@ -4,6 +4,7 @@
#include <optional>
#include <string>
#include <vector>
namespace visione::auth
{
@@ -12,12 +13,16 @@ struct UserContext
long long id{0};
std::string email;
std::string role;
std::vector<std::string> roles;
std::vector<std::string> permissions;
bool mustChangePassword{false};
};
std::optional<std::string> bearerToken(const drogon::HttpRequestPtr &request);
std::optional<UserContext> authenticate(const drogon::HttpRequestPtr &request);
bool hasRole(const UserContext &user, const std::string &role);
bool hasPermission(const UserContext &user, const std::string &permission);
bool canManageReports(const UserContext &user);
bool isAdmin(const UserContext &user);
bool mustChangePassword(const UserContext &user);
} // namespace visione::auth
+61 -4
View File
@@ -20,6 +20,19 @@ std::optional<visione::auth::UserContext> requireUser(
return user;
}
bool blockPasswordChangeRequired(
const visione::auth::UserContext &user,
std::function<void(const HttpResponsePtr &)> &callback)
{
if (!visione::auth::mustChangePassword(user))
{
return false;
}
callback(visione::http::error("password_change_required",
drogon::k403Forbidden));
return true;
}
void sendAdmin(Json::Value body,
std::function<void(const HttpResponsePtr &)> &&callback)
{
@@ -28,6 +41,7 @@ void sendAdmin(Json::Value body,
callback(visione::http::json(std::move(body)));
return;
}
const auto error = body.get("error", "").asString();
const auto status = error == "forbidden" ? drogon::k403Forbidden
: drogon::k400BadRequest;
@@ -48,7 +62,7 @@ void AdminController::createCategory(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -70,7 +84,7 @@ void AdminController::createArea(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -92,7 +106,7 @@ void AdminController::createTeam(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -106,7 +120,7 @@ void AdminController::overviewStats(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -114,3 +128,46 @@ void AdminController::overviewStats(
sendAdmin(service.overviewStats(*user), std::move(callback));
}
void AdminController::listUsers(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
visione::services::AdminService service;
sendAdmin(service.listUsers(*user), std::move(callback));
}
void AdminController::updateUserRoles(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long userId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
visione::services::AdminService service;
sendAdmin(service.updateUserRoles(*user, userId, visione::http::requireJson(req)),
std::move(callback));
}
void AdminController::updateUserBan(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
long long userId)
{
auto user = requireUser(req, callback);
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
visione::services::AdminService service;
sendAdmin(service.updateUserBan(*user, userId, visione::http::requireJson(req)),
std::move(callback));
}
+18
View File
@@ -19,6 +19,13 @@ class AdminController : public drogon::HttpController<AdminController>
ADD_METHOD_TO(AdminController::overviewStats,
"/api/v1/admin/stats/overview",
drogon::Get);
ADD_METHOD_TO(AdminController::listUsers, "/api/v1/admin/users", drogon::Get);
ADD_METHOD_TO(AdminController::updateUserRoles,
"/api/v1/admin/users/{1}/roles",
drogon::Patch);
ADD_METHOD_TO(AdminController::updateUserBan,
"/api/v1/admin/users/{1}/ban",
drogon::Patch);
METHOD_LIST_END
void listCategories(
@@ -42,5 +49,16 @@ class AdminController : public drogon::HttpController<AdminController>
void overviewStats(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void listUsers(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void updateUserRoles(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long userId);
void updateUserBan(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long userId);
};
+22 -1
View File
@@ -81,6 +81,28 @@ void AuthController::loginWithPassword(
serviceResponse(service.loginWithPassword(body), std::move(callback));
}
void AuthController::changePassword(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
{
const auto user = visione::auth::authenticate(req);
if (!user)
{
callback(visione::http::error("unauthorized", drogon::k401Unauthorized));
return;
}
const auto body = visione::http::requireJson(req);
if (body.isNull())
{
callback(visione::http::error("invalid_json", drogon::k400BadRequest));
return;
}
visione::services::AuthService service;
serviceResponse(service.changePassword(*user, body), std::move(callback));
}
void AuthController::me(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback)
@@ -95,4 +117,3 @@ void AuthController::me(
visione::services::AuthService service;
callback(visione::http::json(service.currentUser(*user)));
}
+6 -1
View File
@@ -18,6 +18,9 @@ class AuthController : public drogon::HttpController<AuthController>
ADD_METHOD_TO(AuthController::loginWithPassword,
"/api/v1/auth/login/password",
drogon::Post);
ADD_METHOD_TO(AuthController::changePassword,
"/api/v1/auth/change-password",
drogon::Post);
ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get);
METHOD_LIST_END
@@ -33,7 +36,9 @@ class AuthController : public drogon::HttpController<AuthController>
void loginWithPassword(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void changePassword(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void me(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
};
+22 -9
View File
@@ -36,6 +36,19 @@ std::optional<visione::auth::UserContext> requireUser(
return user;
}
bool blockPasswordChangeRequired(
const visione::auth::UserContext &user,
std::function<void(const HttpResponsePtr &)> &callback)
{
if (!visione::auth::mustChangePassword(user))
{
return false;
}
callback(visione::http::error("password_change_required",
drogon::k403Forbidden));
return true;
}
void sendService(Json::Value body,
std::function<void(const HttpResponsePtr &)> &&callback)
{
@@ -56,7 +69,7 @@ void ReportController::create(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -76,7 +89,7 @@ void ReportController::list(
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -95,7 +108,7 @@ void ReportController::detail(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -109,7 +122,7 @@ void ReportController::updateStatus(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -124,7 +137,7 @@ void ReportController::addMessage(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -139,7 +152,7 @@ void ReportController::listMessages(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -153,7 +166,7 @@ void ReportController::assignTeam(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -168,7 +181,7 @@ void ReportController::addFeedback(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
@@ -183,7 +196,7 @@ void ReportController::uploadPhotos(
long long reportId)
{
auto user = requireUser(req, callback);
if (!user)
if (!user || blockPasswordChangeRequired(*user, callback))
{
return;
}
+4
View File
@@ -11,6 +11,7 @@ Base path: `/api/v1`.
- `POST /auth/otp/request`: send an OTP email through MailHog.
- `POST /auth/otp/verify`: verify OTP and create an API token.
- `POST /auth/login/password`: password login fallback.
- `POST /auth/change-password`: change the authenticated user's password.
- `GET /me`: return the authenticated user.
Protected endpoints use:
@@ -35,3 +36,6 @@ Authorization: Bearer <token>
- `GET|POST /admin/areas`
- `GET|POST /admin/teams`
- `GET /admin/stats/overview`
- `GET /admin/users`
- `PATCH /admin/users/{id}/roles`
- `PATCH /admin/users/{id}/ban`
+3 -1
View File
@@ -11,6 +11,9 @@ assign and resolve interventions while preserving history and basic analytics.
reports and messages citizens.
- `admin`: manages users, roles, reference data, teams, areas and all reports.
The system admin seed must change password before using protected operational
or administrative APIs.
## Report Statuses
- `OPEN`: created and waiting for review.
- `UNDER_REVIEW`: an operator is checking the report.
@@ -33,4 +36,3 @@ assign and resolve interventions while preserving history and basic analytics.
- Route optimization.
- Public open data export.
- Full Docker packaging of the backend.
+95 -1
View File
@@ -137,5 +137,99 @@ Json::Value AdminService::overviewStats(const auth::UserContext &user)
"FROM reports WHERE status IN ('RESOLVED', 'CLOSED')"));
return response;
}
} // namespace visione::services
Json::Value AdminService::listUsers(const auth::UserContext &user)
{
if (!auth::isAdmin(user))
{
return failure("forbidden");
}
auto response = success();
response["users"] = repositories::resultToJson(
drogon::app().getDbClient()->execSqlSync(
"SELECT u.id, u.first_name, u.last_name, u.email, u.fiscal_code, "
"u.phone, u.points, u.banned, u.must_change_password, "
"GROUP_CONCAT(r.name ORDER BY r.name SEPARATOR ',') AS roles "
"FROM users u "
"LEFT JOIN user_roles ur ON ur.user_id = u.id "
"LEFT JOIN roles r ON r.id = ur.role_id "
"GROUP BY u.id, u.first_name, u.last_name, u.email, u.fiscal_code, "
"u.phone, u.points, u.banned, u.must_change_password "
"ORDER BY u.created_at DESC LIMIT 200"));
return response;
}
Json::Value AdminService::updateUserRoles(const auth::UserContext &user,
long long targetUserId,
const Json::Value &body)
{
if (!auth::isAdmin(user) || !body["roles"].isArray())
{
return failure("invalid_roles");
}
auto db = drogon::app().getDbClient();
const auto target =
db->execSqlSync("SELECT id FROM users WHERE id = ?", targetUserId);
if (target.empty())
{
return failure("user_not_found");
}
db->execSqlSync("DELETE FROM user_roles WHERE user_id = ?", targetUserId);
for (const auto &role : body["roles"])
{
if (!role.isString())
{
return failure("invalid_roles");
}
const auto roleRows =
db->execSqlSync("SELECT id FROM roles WHERE name = ?", role.asString());
if (roleRows.empty())
{
return failure("role_not_found");
}
db->execSqlSync(
"INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)",
targetUserId,
roleRows[0]["id"].as<long long>());
}
return success();
}
Json::Value AdminService::updateUserBan(const auth::UserContext &user,
long long targetUserId,
const Json::Value &body)
{
if (!auth::isAdmin(user) || !body["banned"].isBool())
{
return failure("invalid_ban_payload");
}
if (targetUserId == user.id)
{
return failure("cannot_ban_self");
}
auto db = drogon::app().getDbClient();
const auto target =
db->execSqlSync("SELECT id FROM users WHERE id = ?", targetUserId);
if (target.empty())
{
return failure("user_not_found");
}
db->execSqlSync("UPDATE users SET banned = ? WHERE id = ?",
body["banned"].asBool(),
targetUserId);
if (body["banned"].asBool())
{
db->execSqlSync(
"UPDATE api_tokens SET revoked_at = NOW() "
"WHERE user_id = ? AND revoked_at IS NULL",
targetUserId);
}
return success();
}
} // namespace visione::services
+7 -1
View File
@@ -16,6 +16,12 @@ class AdminService
Json::Value listTeams();
Json::Value createTeam(const auth::UserContext &user, const Json::Value &body);
Json::Value overviewStats(const auth::UserContext &user);
Json::Value listUsers(const auth::UserContext &user);
Json::Value updateUserRoles(const auth::UserContext &user,
long long targetUserId,
const Json::Value &body);
Json::Value updateUserBan(const auth::UserContext &user,
long long targetUserId,
const Json::Value &body);
};
} // namespace visione::services
+42 -1
View File
@@ -205,6 +205,40 @@ Json::Value AuthService::loginWithPassword(const Json::Value &body)
return issueToken(user[0]["id"].as<long long>());
}
Json::Value AuthService::changePassword(const auth::UserContext &user,
const Json::Value &body)
{
const auto missing =
validation::missingStrings(body, {"current_password", "new_password"});
if (!missing.empty())
{
return failure("missing_fields");
}
if (body["new_password"].asString().size() < 10)
{
return failure("weak_password");
}
auto db = drogon::app().getDbClient();
const auto current = db->execSqlSync(
"SELECT password_hash FROM users WHERE id = ? AND banned = FALSE",
user.id);
if (current.empty() ||
!crypto::verifyPassword(
body["current_password"].asString(),
current[0]["password_hash"].as<std::string>()))
{
return failure("invalid_credentials");
}
db->execSqlSync(
"UPDATE users SET password_hash = ?, must_change_password = FALSE "
"WHERE id = ?",
crypto::hashPassword(body["new_password"].asString()),
user.id);
return success();
}
Json::Value AuthService::currentUser(const auth::UserContext &user)
{
auto db = drogon::app().getDbClient();
@@ -226,8 +260,15 @@ Json::Value AuthService::currentUser(const auth::UserContext &user)
response["user"]["must_change_password"] =
result[0]["must_change_password"].as<bool>();
response["user"]["role"] = user.role;
for (const auto &role : user.roles)
{
response["user"]["roles"].append(role);
}
for (const auto &permission : user.permissions)
{
response["user"]["permissions"].append(permission);
}
}
return response;
}
} // namespace visione::services
+1 -1
View File
@@ -13,7 +13,7 @@ class AuthService
Json::Value requestOtp(const Json::Value &body);
Json::Value verifyOtp(const Json::Value &body);
Json::Value loginWithPassword(const Json::Value &body);
Json::Value changePassword(const auth::UserContext &user, const Json::Value &body);
Json::Value currentUser(const auth::UserContext &user);
};
} // namespace visione::services