Skip to content

Commit

Permalink
✨ (oauth) Allow impersonation from a decidim user id
Browse files Browse the repository at this point in the history
System can impersonate users knowing the id over the username
  • Loading branch information
Hadrien Froger committed Dec 4, 2024
1 parent d5700b5 commit 7c4a6d8
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 91 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# frozen_string_literal: true

module Decidim
module RestFull
class ImpersonateResourceOwnerFromCredentials < Decidim::Command
attr_reader :api_client, :params, :current_organization

def initialize(api_client, params, current_organization)
@api_client = api_client
@params = params
@current_organization = current_organization
end

def call
ability.authorize! :impersonate, Decidim::RestFull::ApiClient
validate_params!
user = user_from_params
if user
# Update meta data
user.update!(
extended_data: (user.extended_data || {}).merge(
extra
)
)
else
# Create user
user = create_user_from_params!
end
broadcast(:ok, user)
user_from_params
rescue StandardError => e
broadcast(:error, e.message)
end

private

def validate_params!
has_id = params.has_key? :id
has_username = params.has_key? :username
wants_register = meta["register_on_missing"]
user_exists = user_from_params

return true if user_exists

# It does not exists, and do not want to register.
raise StandardError, "User not found. To create one, user meta.register_on_missing" unless wants_register

# It does not exists, want to register, but has no username
raise StandardError, "Param .username required. Check your impersonation payload" unless has_username
# It does not exists, want to register, but already gave an id
raise StandardError, "Param .id forbidden. Check your impersonation payload" if has_id

true
end

def create_user_from_params!
email = meta.delete("email") || "#{username}@example.org"
name = meta.delete("name") || username.titleize

user = current_organization.users.build(
email: email,
name: name,
nickname: username,
extended_data: extra
)
user.accepted_tos_version = if meta["accept_tos_on_register"]
current_organization.tos_version + 1.hour
else
# Will need to revalidate tos
current_organization.tos_version - 1.hour
end
user.tos_agreement = true

password = begin
special_chars = ["@", "#", "$", "%", "^", "&", "*", "-", "_", "+", "=", "~"]
part1 = ::Devise.friendly_token.first((20 + 1) / 2)
special_part = special_chars.sample(2).join
part2 = ::Devise.friendly_token.first((20 + 1) / 2)

# Combine parts to form the final password
password = part1 + special_part + part2
password.chars.shuffle.join
end
user.password = user.password_confirmation = password
user.skip_confirmation! if meta["skip_confirmation_on_register"]

raise StandardError, user.errors.full_messages unless user.valid?

user.save!
user
end

def user_from_params
@user_from_params ||= if params.has_key? "id"
Decidim::User.find_by(
id: params[:id],
organization: current_organization
)
else
Decidim::User.find_by(
nickname: username,
organization: current_organization
)
end
end

def extra
@extra ||= params[:extra] || {}
end

def default_meta
{
"register_on_missing" => false,
"accept_tos_on_register" => false,
"skip_confirmation_on_register" => false
}
end

def meta
@meta ||= begin
user_meta = params[:meta] || {}
default_meta.merge(user_meta)
end
end

def username
raise StandardError, "Username params required" unless params[:username]

@username ||= params[:username]
end

def ability
@ability ||= Decidim::RestFull::Ability.new(api_client)
end
end
end
end
86 changes: 18 additions & 68 deletions lib/decidim/rest_full/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,77 +40,27 @@ class Engine < ::Rails::Engine
ability = Decidim::RestFull::Ability.new(api_client)
case auth_type
when "impersonate"
ability.authorize! :impersonate, Decidim::RestFull::ApiClient
username = params.require("username")
user = Decidim::User.find_by(
nickname: username,
organization: current_organization
)
extra = if params.has_key? :extra
params[:extra].permit!.to_h
else
{}
end

if user
user.update!(
extended_data: (user.extended_data || {}).merge(
extra
)
)
else
default_meta = {
"register_on_missing" => false,
"accept_tos_on_register" => false,
"skip_confirmation_on_register" => false,
"email" => "#{username}@example.org",
"name" => username.titleize
}
user_meta = if params[:meta]
params[:meta].permit(
:register_on_missing,
:accept_tos_on_register,
:skip_confirmation_on_register,
:name,
:email
).to_h
else
{}
end
meta = default_meta.merge(user_meta)
raise ::Doorkeeper::Errors::DoorkeeperError, "User not found" unless meta["register_on_missing"]

email = meta.delete("email")
name = meta.delete("name")
user = current_organization.users.build(
email: email,
name: name,
nickname: username,
extended_data: extra
)
user.accepted_tos_version = if meta["accept_tos_on_register"]
current_organization.tos_version + 1.hour
else
# Will need to revalidate tos
current_organization.tos_version - 1.hour
end
user.tos_agreement = true
impersonation_payload = params.permit(
:username,
:id,
meta: [:register_on_missing, :accept_tos_on_register, :skip_confirmation_on_register, :name, :email]
).to_h
impersonation_payload.merge!({ extra: params[:extra].permit!.to_h }) if params.has_key? :extra

password = begin
special_chars = ["@", "#", "$", "%", "^", "&", "*", "-", "_", "+", "=", "~"]
part1 = ::Devise.friendly_token.first((20 + 1) / 2)
special_part = special_chars.sample(2).join
part2 = ::Devise.friendly_token.first((20 + 1) / 2)

# Combine parts to form the final password
password = part1 + special_part + part2
password.chars.shuffle.join
command_result = ImpersonateResourceOwnerFromCredentials.call(
api_client,
impersonation_payload,
current_organization
) do
on(:ok) do |user|
user
end
on(:error) do |error_message|
raise ::Doorkeeper::Errors::DoorkeeperError, error_message
end
user.password = user.password_confirmation = password
user.skip_confirmation! if meta["skip_confirmation_on_register"]
user.save!
end
user

command_result[:ok]
when "login"
ability.authorize! :login, Decidim::RestFull::ApiClient
user = Decidim::User.find_by(
Expand Down
22 changes: 22 additions & 0 deletions spec/decidim/rest_full/oauth/token_ropc_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,28 @@ def uniq_nickname
end
end

context "with auth_type=impersonate and finding by id" do
let(:body) do
{
grant_type: "password",
auth_type: "impersonate",
id: user.id,
client_id: api_client.client_id,
client_secret: api_client.client_secret,
scope: "public"
}
end

run_test!(example_name: :ok_ropc_impersonate_with_extra) do |response|
json_response = JSON.parse(response.body)
expect(json_response["access_token"]).to be_present
access_token = Doorkeeper::AccessToken.find_by(token: json_response["access_token"])
expect(
access_token.resource_owner_id
).to eq(user.id)
end
end

context "with auth_type=impersonate" do
let(:body) do
{
Expand Down
18 changes: 17 additions & 1 deletion spec/decidim/rest_full/system/users_index_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
end
end

context "with filter[extra_cont]" do
context "with filter[extra_cont] results" do
before do
create(:user, nickname: "specific-data", extended_data: { foo: "bar" }, organization: organization)
create_list(:user, 5, organization: organization)
Expand All @@ -80,6 +80,22 @@
end
end

context "with filter[extra_cont], no results" do
before do
create(:user, nickname: "specific-data", extended_data: { foo: "404" }, organization: organization)
create_list(:user, 5, organization: organization)
end

let(:"filter[extra_cont]") do
'"foo": "bar"'
end

run_test!(example_name: :filter_by_extended_data) do |example|
data = JSON.parse(example.body)["data"]
expect(data.size).to eq(0)
end
end

context "with filter[nickname_eq]" do
before do
create(:user, nickname: "blue-panda-218", organization: organization)
Expand Down
3 changes: 2 additions & 1 deletion spec/decidim/rest_full/test/definitions/password_grant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ module Definitions
grant_type: { type: :string, enum: ["password"], description: "Resource Owner Password Credentials (ROPC) Flow, for **user impersonation**" },
auth_type: { type: :string, enum: ["impersonate"], description: "Type of ROPC" },
username: { type: :string, description: "User nickname, unique and at least 6 alphanumeric chars." },
id: { type: :string, description: "User id, will find over id and ignore username. Fails if register_on_missing=true." },
extra: {
type: :object,
title: "User extra data",
Expand All @@ -51,7 +52,7 @@ module Definitions
scope: { type: :string, enum: Doorkeeper.configuration.scopes.to_a.reject { |scope| scope == "system" }, description: "Request scopes" }
},
additionalProperties: false,
required: %w(grant_type client_id client_secret scope username auth_type)
required: %w(grant_type client_id client_secret scope auth_type)
}.freeze
end
end
47 changes: 26 additions & 21 deletions website/docs/user_documentation/auth/user-credential-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,34 @@ The ROPC flows allows two kind of authentication, use the auth_type attribute to

Use the `grant_type=password` with user credentials to request an access token. Ensure your OAuth application has the correct client ID, client secret, and scopes.

### Required Parameters
### Parameters

- **`grant_type`**: Must be `password`.
- **`auth_type`**: Defines the impersonation type (`login` or `impersonate`).
- **`username`**: The user's unique identifier (e.g., nickname or email).
- **`password`**: The user's password.
- **`client_id`**: Your OAuth application Client ID.
- **`client_secret`**: Your OAuth application Client Secret.
- **`scope`**: The permissions requested (e.g., `public proposals`).
**login auth type**
- required: **`grant_type`**: Must be `password`.
- required: **`auth_type`**: Must be `login`.
- required: **`username`**: The user's unique identifier (e.g., nickname or email).
- required: **`password`**: The user's password.
- required: **`client_id`**: Your OAuth application Client ID.
- required: **`client_secret`**: Your OAuth application Client Secret.
- required: **`scope`**: The permissions requested (e.g., `public proposals`).

**impersonation auth type**
- required: **`grant_type`**: Must be `password`.
- required: **`auth_type`**: Must be `impersonate`
- **`username`**: The user's unique identifier (e.g., nickname or email). Required if `id` is not present.
- required: **`password`**: The user's password.
- required: **`client_id`**: Your OAuth application Client ID.
- required: **`client_secret`**: Your OAuth application Client Secret.
- required: **`scope`**: The permissions requested (e.g., `public proposals`).
- **`meta`**:
- **`register_on_missing`**: If user not found, create one
- **`accept_tos_on_register`**: If the user has already accepted the tos
- **`skip_confirmation_on_register`**: Don't send a confirmation email, and confirm it directly
- **`name`**: The profile public name, used only if `register_on_missing=true`
- **`email`**: The profile email, used only if `register_on_missing=true`
- **`extra`**: Any extra fields for the user. Will be updated on found / creation.

### Example with `curl`

```bash
curl -X POST https://<organization-host>/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "password",
"auth_type": "impersonate",
"username": "<user_nickname>",
"client_id": "<client_id>",
"client_secret": "<client_secret>",
"scope": "public proposals"
}'
```

### Example Reponse
```json
Expand All @@ -60,6 +64,7 @@ curl -X POST https://<organization-host>/oauth/token \
}
```


## Error Handling
### Invalid Credentials
```json
Expand Down

0 comments on commit 7c4a6d8

Please sign in to comment.