diff --git a/common/Auth.cc b/common/Auth.cc index 156c43c..0a44276 100644 --- a/common/Auth.cc +++ b/common/Auth.cc @@ -4,8 +4,26 @@ #include +#include + namespace visione::auth { +namespace +{ +std::string primaryRole(const std::vector &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 bearerToken(const drogon::HttpRequestPtr &request) { const auto header = request->getHeader("Authorization"); @@ -26,41 +44,82 @@ std::optional 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(), - row["email"].as(), - row["role"].as()}; + const auto userId = userResult[0]["id"].as(); + 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 roles; + roles.reserve(roleRows.size()); + for (const auto &row : roleRows) + { + roles.push_back(row["name"].as()); + } + if (roles.empty()) + { + roles.push_back("citizen"); + } + + std::vector permissions; + permissions.reserve(permissionRows.size()); + for (const auto &row : permissionRows) + { + permissions.push_back(row["name"].as()); + } + + UserContext context; + context.id = userId; + context.email = userResult[0]["email"].as(); + context.roles = std::move(roles); + context.permissions = std::move(permissions); + context.role = primaryRole(context.roles); + context.mustChangePassword = userResult[0]["must_change_password"].as(); + 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 - diff --git a/common/Auth.h b/common/Auth.h index 99ce4af..df4fe9a 100644 --- a/common/Auth.h +++ b/common/Auth.h @@ -4,6 +4,7 @@ #include #include +#include namespace visione::auth { @@ -12,12 +13,16 @@ struct UserContext long long id{0}; std::string email; std::string role; + std::vector roles; + std::vector permissions; + bool mustChangePassword{false}; }; std::optional bearerToken(const drogon::HttpRequestPtr &request); std::optional 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 - diff --git a/controllers/AdminController.cc b/controllers/AdminController.cc index 954cd86..75c0b3c 100644 --- a/controllers/AdminController.cc +++ b/controllers/AdminController.cc @@ -20,6 +20,19 @@ std::optional requireUser( return user; } +bool blockPasswordChangeRequired( + const visione::auth::UserContext &user, + std::function &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 &&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 &&callback) { auto user = requireUser(req, callback); - if (!user) + if (!user || blockPasswordChangeRequired(*user, callback)) { return; } @@ -70,7 +84,7 @@ void AdminController::createArea( std::function &&callback) { auto user = requireUser(req, callback); - if (!user) + if (!user || blockPasswordChangeRequired(*user, callback)) { return; } @@ -92,7 +106,7 @@ void AdminController::createTeam( std::function &&callback) { auto user = requireUser(req, callback); - if (!user) + if (!user || blockPasswordChangeRequired(*user, callback)) { return; } @@ -106,7 +120,7 @@ void AdminController::overviewStats( std::function &&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 &&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 &&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 &&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)); +} + diff --git a/controllers/AdminController.h b/controllers/AdminController.h index 552a3c1..46ae7a1 100644 --- a/controllers/AdminController.h +++ b/controllers/AdminController.h @@ -19,6 +19,13 @@ class AdminController : public drogon::HttpController 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 void overviewStats( const drogon::HttpRequestPtr &req, std::function &&callback); + void listUsers( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void updateUserRoles( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long userId); + void updateUserBan( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long userId); }; diff --git a/controllers/AuthController.cc b/controllers/AuthController.cc index 8f8a675..b7630b6 100644 --- a/controllers/AuthController.cc +++ b/controllers/AuthController.cc @@ -81,6 +81,28 @@ void AuthController::loginWithPassword( serviceResponse(service.loginWithPassword(body), std::move(callback)); } +void AuthController::changePassword( + const drogon::HttpRequestPtr &req, + std::function &&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 &&callback) @@ -95,4 +117,3 @@ void AuthController::me( visione::services::AuthService service; callback(visione::http::json(service.currentUser(*user))); } - diff --git a/controllers/AuthController.h b/controllers/AuthController.h index 41839df..cfbbd10 100644 --- a/controllers/AuthController.h +++ b/controllers/AuthController.h @@ -18,6 +18,9 @@ class AuthController : public drogon::HttpController 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 void loginWithPassword( const drogon::HttpRequestPtr &req, std::function &&callback); + void changePassword( + const drogon::HttpRequestPtr &req, + std::function &&callback); void me(const drogon::HttpRequestPtr &req, std::function &&callback); }; - diff --git a/controllers/ReportController.cc b/controllers/ReportController.cc index 4f067ab..8fcf3f5 100644 --- a/controllers/ReportController.cc +++ b/controllers/ReportController.cc @@ -36,6 +36,19 @@ std::optional requireUser( return user; } +bool blockPasswordChangeRequired( + const visione::auth::UserContext &user, + std::function &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 &&callback) { @@ -56,7 +69,7 @@ void ReportController::create( std::function &&callback) { auto user = requireUser(req, callback); - if (!user) + if (!user || blockPasswordChangeRequired(*user, callback)) { return; } @@ -76,7 +89,7 @@ void ReportController::list( std::function &&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; } diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index 3ff3ee5..30e70de 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -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 - `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` diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index 2735b3b..344d560 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -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. - diff --git a/services/AdminService.cc b/services/AdminService.cc index 9caff63..d6dab35 100644 --- a/services/AdminService.cc +++ b/services/AdminService.cc @@ -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()); + } + 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 diff --git a/services/AdminService.h b/services/AdminService.h index 12a3213..d336170 100644 --- a/services/AdminService.h +++ b/services/AdminService.h @@ -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 - diff --git a/services/AuthService.cc b/services/AuthService.cc index 0160e8a..7809d99 100644 --- a/services/AuthService.cc +++ b/services/AuthService.cc @@ -205,6 +205,40 @@ Json::Value AuthService::loginWithPassword(const Json::Value &body) return issueToken(user[0]["id"].as()); } +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())) + { + 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(); 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 - diff --git a/services/AuthService.h b/services/AuthService.h index ec6a07f..e9fa18c 100644 --- a/services/AuthService.h +++ b/services/AuthService.h @@ -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 -